Scaffold and prove the package
uv run druks create app night_watch scaffolds with
that checkout’s CLI instead.
The command writes a standalone druks-night_watch project in the current
directory. Its pyproject.toml contains:
[a-z][a-z0-9_]*. It becomes the API namespace, table
prefix, migration version-table suffix, and settings namespace. Installing the
distribution is the registration:
AGENTS.md holding the contracts a coding agent
cannot infer from the stubs, and a link back to this guide.
The scaffold depends on the published druks. To develop an app against a
local checkout instead, pin it:
Package layout
The scaffold separates self-registering capability modules from ordinary package modules:
Druks recursively discovers leaf modules named
workflows, tasks, routes,
subscribers, webhooks, and services. A capability hidden in workflow.py is not
discovered. Ordinary names such as policy.py and workspace.py have no import
side effect unless a discovered module imports them.
Declare the app
routes modules under /api/night_watch, supplies
transcript routes, and serves druks_night_watch/dist/ under
/app/night_watch when it contains entry.js.
Choose the right workflow shape
The parameters ofrun() or run_multistep() are the workflow input. Druks
builds a Pydantic model from their annotations and validates the call to
start().
If the whole body is one durable operation, use run():
run_multistep().
Also use it for a workflow that waits on a gate:
run() as one step, so it must not carry @step.
DBOS replays run_multistep() orchestration, so it must not carry @step.
Decorate its side-effecting operations instead. An agent called directly from
the orchestration body gets its own step. An agent called inside @step or
run() shares that enclosing checkpoint.
Declare the sandbox environment
A workflow can ship the environment its agents need as a plain shell file:site_builder/sandboxes/build.sh. The path is relative to the
app package.
Druks reads the raw bytes. It does not render
the file or run it during import. Drukbox builds a reusable template from the
platform base and the script.
A run waits with a visible sandbox-building phase when that template is still
building. A workflow with no declaration uses the platform base unchanged.
The content hash of the base and script identifies the template. App authors do
not name provider images.
Use provider idempotency keys for writes. An interrupted operation can run again.
DBOS reuses completed checkpoints on recovery. Keep decisions in replayable
control flow. Keep I/O inside steps. See
durability and recovery.
Start a workflow with an explicit subject — an instance of the class it declares:
subject=None. A subject has
at most one active run for each workflow kind. A duplicate start returns the
active run ID. Attribution does not change this rule. Two accounts that start
the same subject share one run.
If the app requires prelaunch policy, wrap
start() in a domain dispatch() method. This method can own lookup, snapshot,
or routing policy.
A browser start attributes itself. The request identity gate records the
resolved account, and start() inherits it. A route does not require more
attribution code. If the dispatcher has a better account, pass account_id.
For example, a webhook can resolve the ticket assignee.
Each agent call uses the connection of the run account. If that connection is
absent, the call uses the installation fallback account. The call records the
charged account. Thus, you can see fallback use.
A cron or background run
without an account uses the system account. A parked run keeps its original
attribution after resume. The person who selects Resume does not become the
payer.
The journal
Druks keeps a journal of the typed values for each run. Each body-level agent call and gate reply enters it in call order. Add your values withself.journal.add(). Read them by contract type:
Journal to name your projections, and declare it on the workflow:
- Druks journals only body-level calls. An agent call inside a
@step— or in arun()body, which is one big step — never enters it. Keep that state in local variables. - Never mutate body-held state inside a
@step. DBOS skips a completed step on replay, so the write disappears.
Announcing domain events
If another component must react to a body action, announce the action:@step.
Schedules and settings
Setevery to declare a cron:
subject) must not start that way. Give it a dispatch() classmethod
and the schedule fires that instead — it resolves the subject and starts the
real run:
dispatch() fires with no arguments, so it must be nullary. Druks
evaluates cron expressions in the operator timezone. The dashboard can retune or
disable a declared schedule but cannot invent a new workflow schedule.
Background tasks
AWorkflow is the right home for work you want on a subject’s timeline — a run
with agent calls, gates, and operator-tunable settings. Plumbing that wants none
of that — periodic maintenance, a fire-and-forget side effect — is a task:
await sync_labels.enqueue(pull_request_id=7) from a route, subscriber, or
workflow body. Never call it inside a @step. Like a workflow, the signature is
the wire contract. Parameters are
annotated. enqueue() validates them and stores JSON. A task keeps no run row
and never reaches the timeline.
It has no subject, gate, or operator settings.
It cannot make agent calls. every= uses a fixed UTC cadence that the code
owns. An operator can retune the every= value of a workflow.
retries= sets retries after the first attempt, both here and on @step.
A workflow can declare its own operator settings:
Add an agent
An agent belongs to the app class. Its family default (claude or codex)
uses the related operator harness setting. A full model name fixes the default.
AgentOutput.to_result() to map the strict agent contract to a domain value.
Override get_artifact() to publish a reviewable artifact.
If an agent produces a file, use File and FileField.
The contract declares the file, Druks transports and serves it, and the app can
persist its stable reference on an app row.
Do not ask the framework to infer domain side effects from agent prose.
The prompt or a subsequent explicit step owns those actions.
Customize the workspace
Every agent uses aWorkspace around a Drukbox sandbox. Override
Workflow.workspace_class and get_workspace_kwargs() for app-specific
workspace behavior. This behavior can clone a repository, mint a short-lived
token, or require an MCP server.
Keep durable state outside the VM. A workflow can set
steps_reuse_sandbox = True to retain one host across a segment. Druks releases
the host at a gate and at workflow exit. It rotates the host near lease expiry.
Borrow a browser session
Declare required logins on the app class. The attribute name and app name form the session identity. The sessions pane asks the operator to sign in:persist session before the stop:
playwright() yields the logged-in browser context. Pages that you open in
this context use the session. NightWatch.acme.cdp() borrows the same browser
and yields the raw CDP URL. Use this URL with a test suite, raw CDP client, or
custom wrapper.
persist=True writes rotated state after each borrow. Use it for sites that
expire an unused login. headless=True is an optional optimization for sites
that do not fingerprint headless browsers.
anonymous=True declares a session that needs no login. A borrow opens a
browser with an empty profile. The operator does not sign in. Use this option
for a public target or app-owned credentials. These credentials can be an
identity header or a token in the URL. An anonymous session stores no state.
Thus, persist=True with it fails during class definition.
If a borrowed browser returns to the login page, raise
BrowserSessionSignedOutError from druks.browser. Druks marks the session
as stale. The sessions pane shows this state and stops new borrows. The run
fails with the same reason. After the operator signs in again, the next
scheduled run can proceed.
Provider selection is an operator concern. App workspace code targets the
Druks sandbox contract, not exe, AWS, or Docker directly.
Wait for input
The gate fields form the reply schema.name fixes the durable gate identity.
This identity selects the receive channel and the gate value of the parked
run. You must declare it because the identity must survive a class rename:
run_multistep():
on_wait() is a checkpointed notification step. The workflow then parks
durably and releases its warm sandbox. The owning external system resumes the
workflow through the gate and its subject:
answer() resolves the subject run that waits on the gate. It raises if no such
run exists. This includes a gate that has an answer or timeout. A subject can
have runs from several workflows. The gate identifies the applicable run.
For a subject-backed decision inside the Druks dashboard, use:
approve and request_changes. Druks shows optional nonblank
context next to the review. With this context, request_changes does not
require answers or a note. Authors must treat that response as another pass and
include the context. A subjectless workflow cannot use in-app review. A
subjectless custom gate must override on_wait() to show the wait.
Without this
override, Druks raises an error instead of a silent park.
Raise FatalError for a deliberate domain stop. If readers need a stable
machine failure code, subclass it. Set code on the subclass. Unexpected exceptions fail
the run and are re-raised to DBOS.
Stop this workflow’s active execution for a subject through the workflow class:
Give runs a subject read-side
A subject is what your runs are about — a repository, a work item, a pull request. It is always a class, and the workflow names it:start(), cancel(), and Gate.answer() enforce the declaration.
A workflow with a subject starts with an instance of that class. A workflow
without a subject passes subject=None.
When the subject is a row you keep — one you list, edit, and show fields from —
subclass StoredSubject instead of Base. The class name is the subject type:
Repository becomes repository.
label. The label is its one-line description. If
the board requires more fields, add a custom summary:
Subject. The platform requires only
an identity. The ID is the full record and its label:
get_for_subject_id() to reject an invalid shape. For example,
owner/repo#7 is a pull request and nonsense returns a 404.
Each subject a workflow declares must implement list_summaries(). The board
reads it and passes the caller. account_id is the signed-in account, or None
outside a request. If each operator has a separate board, use it to scope the
rows. If all operators share one board, ignore it.
A model method never
reads request context. Druks validates the method at load. If it is missing, the
app does not load. The error names the app, the subject, and the
method.
Druks serves the same /api/night_watch/repository surface for both subject
types. This surface contains a board, detail pages, and a live stream. Druks
mounts it for each declared subject. Each response contains your summary, run
status, timeline, agent calls, artifacts, and active question. Override
get_subject_activity() only to add transient app detail, such as
“Building sandbox VM…”.
Pass the subject instance to each component that requires one. This includes a
workflow start, gate answer, or event:
self.subject resolves through the declared class. It is
live, not a snapshot from dispatch. A run can park on a gate for three days.
After resume, it reads the current row. If the row no longer exists, the subject
does not resolve.
Your app names domain outcomes. For example, a work item ships or an operator cancels it.
Druks owns the active run state. Read this state from the status:
status.kind names the workflow currently driving the row and status.gate the
question it stopped on. While a run is active, await repository.get_phase()
returns the step it is on.
Record events and react to signals
Record an event through the app. Druks stamps its ownership:type is the milestone word that the feed reads. There is no presentation hook
to implement. Lifecycle events for subjected workflows are
recorded automatically. Call record_event() inside a platform-bound
transaction such as a request, durable step, or subscriber.
A feed row contains facts, not prose. It contains its kind, workflow, subject
identity, and event payload. A client supplies the words. Give the subject a
label for its one-line description. Each later event for the subject keeps
that label:
subject=Repository selects each workflow for a repository. workflow=Sweep
selects one workflow and its declared subject. Do not use both filters together.
The subscriber body receives its subject with either filter.
Signals deliver at least one time. A subscriber exception propagates. Then the
webhook provider or DBOS retries the publication. Make each reaction idempotent.
Receive webhooks
A webhook authenticates and normalizes provider input. It must publish a domain-neutral signal rather than contain workflow policy:/_external/night_watch/events/. Druks deduplicates a
delivery when the class supplies a delivery key. A failing handler releases the
claim so the provider can retry.
Models and migrations
Models subclassdruks.db.Base and every normal app table starts with
<name>_:
alembic_version_night_watch. Query through druks.db.db_session() inside an
HTTP request, durable step, or other platform-bound session.
HTTP response models subclass druks.schemas.BaseResponse, whose snake_case
fields serialize as camelCase. Request models are ordinary Pydantic models.
Druks mounts each router from a discovered routes.py below the app namespace.
It tags the router with the app name. A router declares only the prefix of its
resource:
agent to create an MCP tool from it. Give the route an
explicit operation_id. Druks prefixes this value with the app name. For
example, operation_id="add_peer" in peer_tracker becomes
peer_tracker_add_peer. The docstring supplies the description.
A GET route is read-only. If a write is non-destructive, declare
x-destructive: false. If a write is idempotent, declare x-idempotent: true.
Safe defaults are destructive
and non-idempotent. Startup refuses a missing operation_id or docstring.
Two spellings run through druks, and which one a segment wears says who owns it:
Thus,
/api/review/pull_request is the subject board for review runs.
/api/review/reviews is the resource that your POST creates. The platform
matches <subject_type> and transcripts before your routers. A custom router
cannot take a platform read, including through a catch-all. Name the router for
its resource to prevent a conflict.
Declare a service
A service identity is the appliance registration at an external provider. A deployment has one identity for each service string. The platform GitHub App is the first service identity. OAuth grants are not service identities. The platform stores them after an operator connection. See Connect provider accounts. Put a credential that only your app uses in its app settings. Declare one class inservices.py. The platform creates the connection card in
Settings. It validates and stores the submitted values. It encrypts
SecretStr fields and stores plain fields as identity facts.
It also reports the state through druks doctor. The class name is the identity.
Druks derives the slug
from it (Gmail → gmail, GoogleCalendar → google_calendar) and derives
the card heading from the slug:
service_identities row and the connect wire. A class
rename changes the slug, rekeys the card, and orphans the connected identity.
Set slug = "gmail" on the class to keep the old key.
Read it back through the same class:
verify classmethod proves the paste against the live provider
before anything replaces a working identity. It returns extra identity facts
to store, and raises ServiceConnectError with a message safe to show — the
platform never echoes what the operator pasted:
required = False on the
class. Doctor then reports a note instead of pending setup.
Key the service for the integration that your app consumes (Gmail), not the
provider (Google). A second integration on the same provider declares its own
service. The operator decides whether each card uses a shared or narrow
registration. This choice controls scope and the effect of a credential problem.
Connect provider accounts (OAuth)
Declare the OAuth endpoints on the service that holds the client credentials. TheSettings model must have client_id and client_secret
fields:
extra_authorize_params declares special consent-query values for the provider. The
platform adds them to every sign-in it starts for the service. The example
shows Google’s: it grants a refresh token only when the consent asks for
access_type=offline with prompt=consent.
identity_endpoint names the provider endpoint that returns the signed-in
account’s facts (email, username, name). Druks calls it once at consent and
shows the facts as the connection label in Settings. identity_scopes are the
scopes that this call requires. Druks adds them to the consent request.
If one identity fact names the provider account, declare identity_key:
"sub" for Google, "id" for GitHub. A fresh sign-in that matches an
existing connection for the same owner updates that row instead of
creating a second one. A revoked row that matches becomes live again and
keeps its id. Without the declaration, each fresh sign-in creates a new
connection.
Some providers have no such endpoint, or return the facts in a different
shape. Override get_identity for them:
abstract = True. The base never
registers. Each subclass inherits everything it declares, Settings
included, and needs nothing beyond its class name:
account_id is the caller: self.account_id in a run body,
current_account_id.get() in a route, the handler’s argument in a
subscriber, the platform’s argument in list_summaries. NightWatch.acme.get(connection_id) returns one connection
when your own row stored its id. Each connection carries id, scopes, identity — the
provider’s facts for the sign-in — account_id — the druks account that
signed it in — and connected_at. The handle serves
live connections only. A revoked connection drops out of get and
list_for_account, but its platform row survives with its owner and
identity. Your rows never need tombstone copies of either.
Your UI starts a sign-in by opening /api/oauth/acme/connect. The platform
requests the combined scopes from each installed app. It stores the connection
for the signed-in user. A fresh
sign-in creates a new connection, unless the service’s identity_key
matches it to an existing connection for the same owner.
To widen an existing connection’s scopes, open
/api/oauth/acme/connect?connection=<id>. Reconsent replaces its tokens.
Reconsent names the row, so it also makes a revoked connection live again
under its old id. A fresh sign-in that matches the identity_key does the
same.
Add ?next=/app/night_watch/accounts to land the user back on your
page after consent instead of the generic “connected” page. next
must be a bare path that starts with /. Druks rejects a URL with a scheme or
host. Thus, the connection flow cannot redirect away from the host. Register
https://<host>/api/oauth/callback as the provider redirect URI. It serves each
service.
React to sign-ins with the signal machinery. The platform publishes
oauth.connected when a consent completes. reconsent is true when the
consent replaced an existing connection’s tokens. This happens on
reconsent by id and on an identity_key match, for a live or a revoked
row.
It publishes oauth.disconnected after a user revokes a connection. A
replacement of the service’s client credentials also publishes this signal.
Revocation is a state, not a deletion: your subscriber can still read the
connection it is told about. Subscribe in subscribers.py:
get_access_token serves a Redis-cached access token and lets only one
refresher run per connection and scope set. This is necessary: two
refreshes at the same time can make the provider revoke the whole
connection. It raises OauthRefreshError when the refresh fails. Then ask
the user to reconnect.
get_access_token(scopes=("profile.read",)) asks the provider for a token with
fewer scopes than the grant. If the token goes to untrusted compute, use it.
Pass a subset of the connection scopes. cached=False bypasses the cache to get
a full-lifetime token.
App settings and checks
An innerAppSettings class defines dashboard-editable knobs and owns their
cross-field coherence:
Literal choices, and
SecretStr, including optional forms. Druks rejects nested Pydantic models.
It redacts secret values and submitted validation errors. Declare a secret
field as Secret.
An unset field is an empty, false SecretStr. Thus,
if self.service_token: reads its state without a guard for
.get_secret_value(). A multiline secret, such as a PEM private key, can use
json_schema_extra={"multiline": True}. The settings form shows a textarea and
keeps newlines. Storage, redaction, and write-only behavior do not change.
section is a plain heading that Druks renders in first-declaration order, with
unsectioned fields first. visible_when takes one same-model {field: value}
equality condition. Its controller must be non-secret and unconditional, and a
Literal controller requires one of its declared members.
Hidden fields keep their stored values. Read the resolved model with
NightWatch.settings(). The settings form runs clean() against the
resolved settings after the proposed edits and rejects an incoherent save. druks doctor
runs the same method over stored settings so rows from older releases or manual database
edits remain visible. Workflow settings stay plain Pydantic BaseModel declarations.
An app can add precondition checks through checks. These checks supplement
settings validation. Return druks.doctor.CheckResult. Druks namespaces the
result. It converts an exception or malformed result into an error without
hiding later checks.
Test an app
The Druks installation registers its pytest plugin. An app can request the fixtures directly without aconftest.py or pytest_plugins declaration:
The fixtures are not autouse. A test that requests
druks_client also gets
druks_db. A test accesses Redis only if it requests druks_redis.
Run a workflow’s body against a subject with no durable engine — no checkpoints,
no lifecycle events, no retries:
@step calls inside a run_multistep body still need the real engine.
Seed platform-owned run and agent-call rows with plain functions:
seed_run writes both the run row and its DBOS workflow status. That status
determines Run.state. seed_run requires kind. If you seed
state="pending_input", pass input_gate. seed_call accepts an Agent or
its string ID.
make_settings(tmp_path, **overrides) builds isolated Druks settings.
configure_app_for_test(settings=..., authenticated=False) returns the mounted
app if a test needs its own client or an unauthenticated request path.
druks_client covers the normal authenticated case.
The fixtures never read the runtime’s settings. They read
DRUKS_TEST_DATABASE_URL and DRUKS_TEST_REDIS_URL. The defaults are a local
druks_test database and Redis index 15. The fixtures point the code under test
to the same pair. Thus, a run cannot use the values in DRUKS_DATABASE_URL or
DRUKS_REDIS_URL.
Create the database one time with createdb druks_test. The
development Compose project already creates it.
On that database, the plugin creates citext and imports installed app models.
It runs SQLAlchemy create_all and seeds platform reference rows. It builds the
DBOS system tables through DBOS database migrations. It does not reset or drop a
schema. It rolls back each test write through druks_db. druks_redis
runs FLUSHDB on the test index.
Frontends
An installed app is visible in the dashboard without a custom UI. The shell reads the installed roster from/api/apps. It gives each app an entry in the
app switcher and generic pages. Each subject type gets a board. Each subject
gets a page with its timeline, transcripts, and gate controls. The subject
summary fields form the board row.
No additional declaration is necessary.
The shell derives the switcher label from name (underscores become spaces).
The app declares chrome contributions as data. navigation on the app class
adds appbar subnav tabs as (url, name) pairs. The shell shows these tabs for
generic pages and shipped frontends. The active tab has the URL that is the
longest prefix of the current location:
druks_night_watch/dist/entry.js. Set the frontend build output to that dist/
directory. The contract uses shellApi: 1:
- Entry module:
entry.jsexportsshellApi = 1andmount(el, ctx). The function renders intoeland returns a dispose function. A missingmountor a version mismatch renders a visible error panel in the shell. - Context:
ctxcarriesapiBase(/api/<name>),navigate(path)for shell-side navigation,theme.accent, andmarkdown(source)— the shell’s own markdown renderer, so an app does not bundle one. The app renders in the shell’s document, so the shell’s CSS variables cascade into it. The shell re-broadcasts every location change as apopstateevent while the app is mounted. - When
dist/style.cssis present, the shell loads it while it mounts the app.
react, react-dom,
react-dom/client, and react/jsx-runtime external. The shell import map
resolves them to its copy. Thus, one React instance serves the document.
Bundle other dependencies as usual. Route by reading location.pathname under
/<name>/.
The bundled Druks SPA also has a shared React app registry. To join this shell,
compile the app UI module into the dashboard image. An installed Python wheel
cannot change an existing JavaScript bundle. See the
frontend guide
for that in-repository path.
Stable author imports
Import from concern namespaces, not fromdruks.durable or internal modules:
The root
druks package deliberately exports only its version.