Documentation — Deploy
Guides · Deploy and operate

Deploy

This guide shows how to run agentd in production: as a one-shot job, as a daemon under systemd, and on Kubernetes as a Job, CronJob, Deployment or StatefulSet. It is for operators. agentd is one binary that runs one durable agent and owns no control plane: your scheduler starts it, stops it, replicates it and watches it, and agentd keeps a contract of signals, exit codes and probes that the scheduler can act on.

Choose a shape

What decides the shape is whether the instance outlives one run. A job runs its work to an end and exits with a code. A daemon waits for triggers, such as an A2A listener, a webhook listener, or a schedule, subscribe, loop or stream start node, and exits only when drained.

ShapeLifecycleStateWhere
One-shot jobends when its run endsnone by default: every effect goes out through MCPa shell, a CI step, a Job, a CronJob
Single daemondrains on SIGTERMa file store on a disk that survives restartssystemd, a Deployment with one replica
Fleeteach replica drains on SIGTERMan mcp or http store shared by all replicasa StatefulSet

lifecycle.run_until: auto, the default, picks the shape from the configuration: anything long-lived makes a daemon. agentd --capabilities reports the verdict as lifecycle.daemon. Jobs, daemons and triggers explains the rules.

Run a one-shot job

A job runs an instruction to an end, prints the result on stdout, writes its telemetry to stderr, and exits with a code from the exit-code table.

export AGENTD_INTELLIGENCE_TOKEN="$OPENAI_API_KEY"
agentd --instruction.file task.md --intelligence https://api.openai.com/v1/chat/completions --model gpt-5.1 --mcp incidents=https://mcp-incidents.internal/mcp --deadline 15m --max-steps 40
  • --instruction.file reads the task from a file; a ConfigMap or Secret projection works well. --prompt "<text>" is the other one-shot form: it delivers one message to the agent, which answers with its full tool surface.
  • Pass the model credential in the environment (AGENTD_INTELLIGENCE_TOKEN) or a file (--intelligence-token-file), never on a shared command line where ps shows it. agentd never logs it.
  • --deadline bounds the whole run, a model call in flight included, and --max-steps bounds its steps. A run past its deadline ends with exit 124; one that runs out of steps or tokens ends with exit 7.

stdout and stderr separate cleanly, so a job composes with shell tools:

agentd --instruction.file task.md --intelligence https://api.openai.com/v1/chat/completions --model gpt-5.1 2> telemetry.ndjson > result.txt

A job keeps no state of its own (store.kind: none is its default), so running it again is safe as long as its side effects are. Make retries idempotent by pinning the run id to the unit of work:

agentd --run-id nightly-digest-2026-10-11 --instruction.file task.md --intelligence https://api.openai.com/v1/chat/completions --model gpt-5.1 --mcp incidents=https://mcp-incidents.internal/mcp

The run id rides in the _meta of every MCP call as agent/run_id, so a server that keys on it can collapse a retried job's effects to one. The per-call agent/idempotency_key beside it stays the same across retries inside one process, not across processes. Without --run-id, every process mints a fresh run id.

Run a daemon

A daemon idles until a trigger fires. This one wakes when a ticket queue resource changes and triages the new ticket:

# /etc/agentd/triage.yaml
agent:
  name: triage
  instruction: When a ticket is filed, triage it and assign an owner.
intelligence:
  endpoints: https://api.openai.com/v1/chat/completions
  token: "{{secret:OPENAI_API_KEY}}"
  models:
    smart: { model: gpt-5.1 }
    fast: { model: gpt-5-mini }
  roles:
    turn: smart
mcp:
  servers:
    - { name: tickets, endpoint: "https://mcp-tickets.internal/mcp" }
store:
  kind: file
  file: { path: /var/lib/agentd }
workflows:
  - name: triage
    steps:
      inbound: { kind: subscribe, server: tickets, uri: "tickets://queue/inbound" }
      work: { kind: agent, depends_on: [inbound], instruction: "Triage the new ticket." }
      done: { kind: finish, depends_on: [work] }
lifecycle:
  run_until: drained
  drain_timeout: 25s
  watch_config: true
observability:
  metrics_addr: ":9090"
agentd --config /etc/agentd/triage.yaml

Check it before you ship it. Validation resolves {{secret:…}} references, so set them, with placeholder values in CI:

OPENAI_API_KEY=placeholder agentd --validate-config --config /etc/agentd/triage.yaml

A daemon is durable. Naming no store gets it kind: file under the state directory chain (AGENTD_STATE_DIR, then $XDG_STATE_HOME/agentd/state, then $HOME/.local/state/agentd/state); store.kind: none on a daemon is refused at load. On restart it restores its runs, timers, artifacts and pending inbox from the store, connects its MCP servers again and re-arms every start node.

Anything the daemon had accepted before it stopped is in the durable inbox and replays. A notification that arrives while the process is down is not queued for it: if a missed update matters, back the work with a durable queue, or add a schedule start that sweeps for outstanding items.

Shut it down cleanly

SIGTERM or SIGINT starts a bounded drain:

  1. Readiness turns off and the triggers stop admitting work. No new workflow step or conversation turn starts. Workflows with an event start on lifecycle.shutdown fire at this point, and only their steps still start.
  2. Work already running runs on: a conversation turn, a step's turn, the think and decide workers serving them, a subagent, an http or MCP call out on an executor thread. Each records its result as usual, and its run then stops at its next step. A warm subagent, which never ends on its own, is cancelled at once; an instance-tier child is sent SIGTERM and drains itself.
  3. The drain waits for that work and for the lifecycle.shutdown runs, up to lifecycle.drain_timeout (default 25 s). A drain the agent's own exit began — a finish with exit: true, lifetime_exhausted: exit — stops that work at once instead, and waits only for the lifecycle.shutdown runs.
  4. agentd checkpoints its durable state, flushes its log and exits.

Nothing the drain stops is failed. When the timeout passes, or a second SIGTERM or SIGINT forces the drain, the children still running are killed and their work is left for the next start: a step whose turn was cut off stays running in the store and runs again, as a step a crash caught running does, a conversation turn's message stays in the inbox and is answered again, and a subagent is recorded parked and started again. One drain.interrupted line names what was left. A run that is waiting (on a timer, a signal, a person, or for its next step) stays in the store as it is. All of it resumes when the instance starts again, on the definitions it started with, so a drain that is stopped loses only the partial work of the calls it cut off. The exception is a lifecycle.shutdown run the drain cut short: it is cancelled, since it belongs to the shutdown that is over, and the next start does not run it at its boot.

A daemon's drain exits 0, not 143, so a rolled pod looks like a clean shutdown. A job's drain exits with the job's own code: one whose work the drain cut short exits 3, never 0, so a Job controller does not record a success for work that did not happen. A job keeps no state by default (store.kind: none), so its next run starts the work again rather than resuming it.

When drain_timeout passes, agentd kills the children that remain; if any is still alive 3 seconds later, it logs drain.abandon and exits anyway. The worst case is therefore drain_timeout plus about 3 seconds. A turn blocked on ask_human holds the drain to its timeout, since the listener takes no answers while draining. Its task goes back to working rather than failing, and the turn runs again after the restart and asks on the same task. A workflow human step holds nothing: it waits in the store.

Keep the drain inside the grace period

agentd cannot see your orchestrator's grace period, so nothing checks the pair for you. If the grace period ends first, the orchestrator's SIGKILL lands mid-drain: the exit becomes 137, the lifecycle.shutdown workflows are cut off, the final checkpoint may not be written, and a rolling update reports failures.

Keep the grace period above drain_timeout plus 3 seconds, with room for the final checkpoint:

lifecycle.drain_timeoutKubernetes terminationGracePeriodSecondssystemd TimeoutStopSec
25s (default)3535
50s6060

The Kubernetes default grace period of 30 seconds leaves only 2 seconds of margin over the default drain. Set both values explicitly.

Under systemd

# /etc/systemd/system/agent-triage.service
[Unit]
Description=agentd ticket triage
Wants=network-online.target
After=network-online.target

[Service]
Type=simple
DynamicUser=yes
StateDirectory=agentd
RuntimeDirectory=agentd
EnvironmentFile=/etc/agentd/triage.env
ExecStart=/usr/local/bin/agentd --config /etc/agentd/triage.yaml --health-file /run/agentd/health.json
ExecReload=/bin/kill -HUP $MAINPID
KillMode=mixed
KillSignal=SIGTERM
TimeoutStopSec=35
Restart=on-failure
RestartSec=5
RestartPreventExitStatus=2 5

[Install]
WantedBy=multi-user.target
  • EnvironmentFile carries the secrets the configuration references, one OPENAI_API_KEY=… line per variable. Keep it mode 0600.
  • StateDirectory=agentd creates /var/lib/agentd, the store path in the configuration, owned by the service's user.
  • KillMode=mixed sends SIGTERM to agentd alone and lets it wind its children down. The default, control-group, signals every child at the same moment and pre-empts the drain.
  • RestartPreventExitStatus=2 5 stops a restart loop on a configuration error or a refused task, which a restart cannot fix.
  • systemctl reload agent-triage sends SIGHUP, which reloads the configuration. Operate a running daemon says what reloads.

Use the container image

The published image is ghcr.io/agentd-dev/agentd, tagged by release (1.20.0, 1.20, 1, latest) for amd64 and arm64. It holds one static musl binary on scratch: no shell, no libc, no package manager. It runs as uid 65532 with /agentd as its entrypoint, and it carries the release feature set, including the A2A listener, /metrics and the probes, cron, OTLP export, hot reload and the mock: model endpoint. Pin a version or a digest in production.

The image is signed with cosign in the release workflow. Verify it before you deploy:

cosign verify ghcr.io/agentd-dev/agentd:1.20.0 --certificate-oidc-issuer https://token.actions.githubusercontent.com --certificate-identity-regexp '^https://github.com/agentd-dev/'

agentd is its own init. It sets itself as the child subreaper and reaps orphans, so it runs as PID 1 without tini.

Build your own image

The Dockerfile at the repository root builds the same image. The feature set is a build argument:

docker build -t agentd:local .
docker build --build-arg FEATURES=a2a,metrics,cron,otel,hot-reload,config-watch -t agentd:narrow .
docker buildx build --platform linux/amd64,linux/arm64 -t registry.example.com/agentd:1.20.0 --push .

examples/docker has a variant on gcr.io/distroless/static-debian12:nonroot that builds without TLS by default, for the loopback-sidecar setup described under TLS.

Features are compiled in; no runtime flag adds one. A setting for a feature the binary lacks is refused at load rather than ignored: an a2a.listen, webhooks.listen or a2a.peers in a build without a2a, a CEL guard without cel, a trust pin without sign. Keep your configuration and your build in step. Build features and crates lists every feature and what it adds.

Where state lives in a container

A file store writes to a directory. In a container, choose where that directory lives on purpose:

  • The container's writable layer survives a process restart in the same pod but not a reschedule: a new pod starts with an empty layer. The scratch image makes this easy to miss, since there is no shell to look with.
  • A mounted volume at store.file.path makes durability the volume's job. Use a PersistentVolumeClaim, and a StatefulSet's volumeClaimTemplates when several pods each need their own.
  • An mcp or http store keeps state in a service, so the pod is disposable. This is the only choice for a fleet.

A file store has a single writer. A second process that opens the same directory exits 6 at startup, naming the holder:

store.file: /var/lib/agentd is locked by pid 1921605 — another agentd is using this state directory; give this instance its own agent.name or store.file.path

agentd creates its directories 0700 and its files 0600. Give the state volume to the uid the container runs as (fsGroup: 65532 on Kubernetes).

With readOnlyRootFilesystem: true, the only paths agentd writes are the ones you name: the state directory, the health file if you set one, and the OAuth credential cache (AGENTD_CRED_DIR) if an endpoint uses --login. Mount each one; an emptyDir is enough for the last two. Set store.file.path explicitly in a container: the default chain may resolve to a path on the read-only root.

TLS

The default build carries TLS with bundled root certificates, so agentd dials https:// model, MCP and A2A endpoints directly. For a private CA, add it with security.tls_ca (--tls-ca); it is trusted beside the bundled roots on every outbound connection. A build with --no-default-features drops TLS and reaches HTTPS only through a TLS-terminating sidecar on a loopback http:// port; any other plain http:// endpoint is refused.

Deploy on Kubernetes

The manifests below use the triage configuration above, mounted from a ConfigMap, and take secrets from a Secret as environment variables. The repository's examples/k8s directory has runnable variants.

Map exit codes to a failure policy

The exit code tells the Job controller whether a retry can help. Codes 2 (configuration) and 5 (refused) never improve with a retry, so fail the Job on them and let everything else count against backoffLimit. CLI, environment and exit codes gives the intent of every code.

# fragment: under the Job's spec:
podFailurePolicy:
  rules:
    - action: FailJob
      onExitCodes:
        operator: In
        values: [2, 5]

A budget stop is exit 7 and a missed deadline exit 124. If either should not count as a failure for your Job, --budget-exit-code 0 remaps 7 (and 3); nothing remaps 124.

A Job pod evicted or deleted mid-run drains and exits 3 when the drain cut its work short, so it counts against backoffLimit and the work runs again in a new pod. A remap of 3 turns that into a success as well.

Job

apiVersion: batch/v1
kind: Job
metadata:
  name: agent-digest
spec:
  backoffLimit: 3
  podFailurePolicy:
    rules:
      - action: FailJob
        onExitCodes:
          operator: In
          values: [2, 5]
  template:
    spec:
      restartPolicy: Never
      terminationGracePeriodSeconds: 35
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        runAsGroup: 65532
      containers:
        - name: agent
          image: ghcr.io/agentd-dev/agentd:1.20.0
          args:
            - --instruction.file
            - /etc/agentd/task.md
            - --intelligence
            - https://api.openai.com/v1/chat/completions
            - --model
            - gpt-5.1
            - --mcp
            - incidents=https://mcp-incidents.internal/mcp
            - --deadline
            - 15m
          env:
            - name: AGENTD_INTELLIGENCE_TOKEN
              valueFrom:
                secretKeyRef: { name: openai, key: api-key }
            - name: AGENTD_RUN_ID
              valueFrom:
                fieldRef: { fieldPath: "metadata.labels['batch.kubernetes.io/job-name']" }
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }
          volumeMounts:
            - { name: task, mountPath: /etc/agentd, readOnly: true }
      volumes:
        - name: task
          configMap: { name: agent-digest-task }

AGENTD_RUN_ID takes the Job's name from the label Kubernetes puts on every Job pod, so every retry of one Job sends the same agent/run_id to its MCP servers. restartPolicy: Never is required for podFailurePolicy.

CronJob

A CronJob that starts a job per tick is the better scheduler for calendar work: it is observable, it lives where your other schedules live, and it handles missed ticks by policy. agentd's own schedule start runs in UTC and skips a missed occurrence rather than replaying it.

apiVersion: batch/v1
kind: CronJob
metadata:
  name: agent-nightly
spec:
  schedule: "0 2 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      backoffLimit: 2
      podFailurePolicy:
        rules:
          - action: FailJob
            onExitCodes:
              operator: In
              values: [2, 5]
      template:
        spec:
          restartPolicy: Never
          terminationGracePeriodSeconds: 35
          containers:
            - name: agent
              image: ghcr.io/agentd-dev/agentd:1.20.0
              args:
                - --instruction.file
                - /etc/agentd/task.md
                - --intelligence
                - https://api.openai.com/v1/chat/completions
                - --model
                - gpt-5.1
              env:
                - name: AGENTD_INTELLIGENCE_TOKEN
                  valueFrom:
                    secretKeyRef: { name: openai, key: api-key }
                - name: AGENTD_RUN_ID
                  valueFrom:
                    fieldRef: { fieldPath: "metadata.labels['batch.kubernetes.io/job-name']" }
              volumeMounts:
                - { name: task, mountPath: /etc/agentd, readOnly: true }
          volumes:
            - name: task
              configMap: { name: agent-nightly-task }

Each tick creates a Job with a new name, so each tick gets its own run id.

Deployment

One durable identity is one process, so a daemon with a file store runs as a single replica with the Recreate strategy. A rolling update would start the new pod while the outgoing pod still holds the store, and the new pod would exit 6.

apiVersion: apps/v1
kind: Deployment
metadata:
  name: agent-triage
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels: { app: agent-triage }
  template:
    metadata:
      labels: { app: agent-triage }
    spec:
      terminationGracePeriodSeconds: 35
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        runAsGroup: 65532
        fsGroup: 65532
      containers:
        - name: agent
          image: ghcr.io/agentd-dev/agentd:1.20.0
          args: ["--config", "/etc/agentd/agentd.yaml"]
          env:
            - name: OPENAI_API_KEY
              valueFrom:
                secretKeyRef: { name: openai, key: api-key }
          ports:
            - { name: probes, containerPort: 9090 }
          livenessProbe:
            httpGet: { path: /healthz, port: probes }
            periodSeconds: 10
            failureThreshold: 3
          readinessProbe:
            httpGet: { path: /readyz, port: probes }
            periodSeconds: 5
            failureThreshold: 2
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }
          resources:
            requests: { cpu: 100m, memory: 256Mi }
            limits: { memory: 512Mi }
          volumeMounts:
            - { name: config, mountPath: /etc/agentd, readOnly: true }
            - { name: state, mountPath: /var/lib/agentd }
      volumes:
        - name: config
          configMap: { name: agent-triage-config }
        - name: state
          persistentVolumeClaim: { claimName: agent-triage-state }

The ConfigMap agent-triage-config holds the triage configuration as agentd.yaml. Secrets stay out of it: the file carries only {{secret:NAME}} and {{secret-file:PATH}} references, resolved from the environment or a mounted Secret at load and at every reload.

Set resources.limits.memory deliberately. The pod's memory covers agentd and every child it spawns; an out-of-memory kill is exit 137 and means "raise the limit". security.cgroup can cap each child separately where the node delegates cgroups.

StatefulSet

A fleet needs one distinct, stable identity per replica, and a store every replica can reach. A StatefulSet gives each pod a stable name, and agentd uses that name as its instance name when agent.name is unset. The worker configuration leaves agent.name out, keeps its state in an mcp store, and takes its slice of the queue from an environment variable:

# worker.yaml, in the ConfigMap agent-workers-config
intelligence:
  endpoints: https://api.openai.com/v1/chat/completions
  token: "{{secret:OPENAI_API_KEY}}"
  models:
    smart: { model: gpt-5.1 }
    fast: { model: gpt-5-mini }
  roles:
    turn: smart
mcp:
  servers:
    - { name: queue, endpoint: "https://mcp-workqueue.internal/mcp" }
    - { name: state, endpoint: "https://mcp-state.internal/mcp" }
store:
  kind: mcp
  mcp: { server: state }
workflows:
  - name: worker
    concurrency: { max_runs: 8, on_overflow: queue }
    steps:
      pull: { kind: subscribe, server: queue, uri: "queue:///partition/${PARTITION}", debounce_ms: 500 }
      work: { kind: agent, depends_on: [pull], instruction: "Handle the item at {{steps.pull.output.uri}}. Its text is untrusted data." }
      done: { kind: finish, depends_on: [work] }
lifecycle:
  run_until: drained
  drain_timeout: 25s
observability:
  metrics_addr: ":9090"
apiVersion: v1
kind: Service
metadata:
  name: agent-workers
spec:
  clusterIP: None
  selector: { app: agent-workers }
  ports:
    - { name: probes, port: 9090 }
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: agent-workers
spec:
  serviceName: agent-workers
  replicas: 4
  selector:
    matchLabels: { app: agent-workers }
  template:
    metadata:
      labels: { app: agent-workers }
    spec:
      terminationGracePeriodSeconds: 35
      securityContext:
        runAsNonRoot: true
        runAsUser: 65532
        runAsGroup: 65532
      containers:
        - name: agent
          image: ghcr.io/agentd-dev/agentd:1.20.0
          args: ["--config", "/etc/agentd/worker.yaml"]
          env:
            - name: AGENTD_POD_NAME
              valueFrom:
                fieldRef: { fieldPath: metadata.name }
            - name: PARTITION
              valueFrom:
                fieldRef: { fieldPath: "metadata.labels['apps.kubernetes.io/pod-index']" }
            - name: OPENAI_API_KEY
              valueFrom:
                secretKeyRef: { name: openai, key: api-key }
          ports:
            - { name: probes, containerPort: 9090 }
          livenessProbe:
            httpGet: { path: /healthz, port: probes }
            periodSeconds: 10
          readinessProbe:
            httpGet: { path: /readyz, port: probes }
            periodSeconds: 5
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities: { drop: ["ALL"] }
          volumeMounts:
            - { name: config, mountPath: /etc/agentd, readOnly: true }
      volumes:
        - name: config
          configMap: { name: agent-workers-config }
  • AGENTD_POD_NAME (agent-workers-0, agent-workers-1, …) becomes the instance name, which namespaces every key the replica writes. A replica that restarts under the same name resumes its own runs.
  • PARTITION comes from the pod-index label Kubernetes 1.28 and later put on StatefulSet pods, and ${PARTITION} in the file expands at load. An unset variable is exit 2, so a missing label fails loudly.
  • Two processes that end up with the same identity are fenced by the store: the loser logs store.conflict and stops writing.

For a fleet with a file store per replica instead, add a volumeClaimTemplates entry mounted at store.file.path; each ordinal re-attaches its own claim. Scale out covers how to split the work so that replicas never process the same item.

Reload configuration from a ConfigMap

With lifecycle.watch_config: true, agentd watches the directory of each configuration file. Applying a new ConfigMap swaps the mounted files atomically; agentd sees the swap, validates the new file and applies the reloadable settings in place. An invalid file changes nothing (config.reload.invalid), and a change to a restart-only setting is refused (config.reload.restart_required): roll the pod for those. Operate a running daemon lists both sets.

A ConfigMap mounted with subPath is not updated by the kubelet; mount the whole ConfigMap as a directory. SIGHUP reloads too, but the scratch image has no kill binary to send it with from inside the pod, so the file watch is the Kubernetes path.

Probes that work on a scratch image

The image has no shell, so exec probes cannot run. Serve the HTTP probes with observability.metrics_addr: ":9090" and point the kubelet at them:

ProbePathFails when
liveness/healthzthe supervisor's event loop has not ticked for 5 s, or a drain is under way
readiness/readyzthe instance is draining, or every model endpoint is reported down

An idle daemon is healthy: liveness tracks the supervisor, not whether work is flowing, and a child stuck on a long tool call never fails it. The health file (--health-file) is for hosts where a checker can read files; on scratch, nothing can. Monitor in production explains each signal.

Manage it over A2A

The A2A listener (a2a.listen) is also the management channel: drain, pause, resume, cancel, read status and the effective configuration. A non-loopback listener must authenticate its callers (mutual TLS, a bearer token, or both) and must use https://; a wildcard bind must name the address callers use in a2a.url. Validation refuses each omission with exit 2, so there is no open control plane. The controller that issues these calls is yours; agentd honours the authenticated identity it presents. Operate a running daemon has every request, and Harden a deployment the listener settings.

Next steps