Lifecycle Hooks¶
Run containers before and after your agent — fetch context from external systems, upload artifacts, notify Slack, clean up resources. Lifecycle hooks let you wire arbitrary setup and teardown logic into the agent execution flow without modifying the agent itself.
How It Works¶
AgentRun created
→ Pending phase
→ Controller creates workspace PVC (if postRun defined)
→ Controller creates lifecycle RBAC (if rbac defined)
→ PreRun init containers execute sequentially
→ Running phase
→ Agent container runs
→ PostRunning phase (if postRun defined)
→ Controller creates follow-up Job
→ PostRun init containers execute sequentially
→ Succeeded / Failed
→ Workspace PVC cleaned up
→ Lifecycle RBAC garbage-collected (owner reference)
PreRun Hooks¶
PreRun hooks execute as init containers before the agent starts. They have access to:
| Path | Description |
|---|---|
/workspace |
Shared working directory — write files here for the agent to read |
/ipc |
IPC bus (tool calls, task input) |
/tmp |
Scratch space |
Use cases: Fetch incident context from PagerDuty, clone a git repo, download test data, warm caches.
Skipping a run¶
A preRun hook can skip the run entirely when there is no work to do — for example, a hook that polls a queue or inbox and finds it empty. This avoids the LLM call and its token cost.
To skip, the hook writes the marker file /ipc/control/skip and exits 0. Any
text written to the file is recorded as the human-readable skip reason. The
agent container then short-circuits before any LLM call, and the AgentRun lands
in the terminal Skipped phase (distinct from Succeeded/Failed).
spec:
lifecycle:
preRun:
- name: check-queue
image: curlimages/curl:latest
command: ["sh", "-c",
"test -s /workspace/queue.json || { mkdir -p /ipc/control && echo 'queue empty' > /ipc/control/skip; }"]
Notes:
- Create the directory. Nothing creates
/ipc/controlbefore preRun hooks run, so the hook mustmkdir -pit before it writes the marker. -
In harness mode, the adapter does the skip. The harness replaces
agent-runner. Sympozium mounts/ipc/controlread-only into the harness container, and the adapter must check the marker and return askippedresult. An adapter that ignores the marker runs the harness anyway. See Skipped runs. -
Exit
0, don't fail. A non-zero exit fails the whole Pod (standard init container behavior) and marks the runFailed— that is not a skip. - When a run is skipped, postRun hooks are bypassed (including gate hooks) and memory is not persisted — there was no agent output to process.
- Channel-triggered runs that are skipped send no reply (the skip is silent).
- Skipped runs are counted separately from successes/failures in gateway metrics
(
skippedCount).
PostRun Hooks¶
PostRun hooks execute in a follow-up Job after the agent completes. They receive everything preRun hooks get, plus:
| Env Var | Description |
|---|---|
AGENT_EXIT_CODE |
"0" on success, non-zero on failure |
AGENT_RESULT |
The agent's final response text (truncated to 32Ki) |
The workspace is shared between the agent and postRun hooks via a PersistentVolumeClaim. PostRun failures are best-effort — they're recorded as a PostRunFailed Condition but don't change the agent's final phase.
PostRun timeouts¶
PostRun hooks run sequentially in one Job, and that Job's budget is the sum of the hooks' timeout fields — 5 minutes each when unset, and never less than 10 minutes in total.
postRun:
- name: upload-artifacts
image: amazon/aws-cli:latest
timeout: 30m # a slow upload gets the room it needs
Two things to know:
- The budget is measured from when the postRun Job starts, not from when the agent run started. A long-running agent still gets its full postRun budget.
- Kubernetes has no per-init-container timeout, so
timeoutbounds the Job as a whole rather than each container. Use it to give a slow hook room, not to police a fast one — a hook that overruns simply eats into what is left for the hooks after it.
Use cases: Upload artifacts to S3, post a summary to Slack, clean up temporary resources, trigger downstream pipelines.
Environment Variables¶
All lifecycle hook containers receive these env vars:
| Env Var | Description |
|---|---|
AGENT_RUN_ID |
Unique identifier for this agent run |
INSTANCE_NAME |
The Agent this run belongs to |
AGENT_NAMESPACE |
Kubernetes namespace |
| Custom env vars | Any spec.env entries from the AgentRun |
RBAC for Hooks¶
Every AgentRun gets a unique ServiceAccount with automatic token mounting disabled. Lifecycle hooks receive no Kubernetes token or permissions by default. If your hooks need to interact with the Kubernetes API (for example, to create or delete ConfigMaps), declare the required RBAC rules:
spec:
lifecycle:
rbac:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["get", "list", "create", "delete"]
preRun:
- name: create-context
image: soldevelo/kubectl:1.36
command: ["kubectl", "create", "configmap", "run-context",
"--from-literal=started=$(date)"]
postRun:
- name: cleanup-context
image: soldevelo/kubectl:1.36
command: ["kubectl", "delete", "configmap", "run-context"]
The controller creates a namespace-scoped Role and RoleBinding for the run, bound only to that AgentRun's ServiceAccount. It projects a pod-bound, 10-minute token into the declared hook containers, not into the agent, harness, IPC bridge, or unrelated sidecars. The ServiceAccount and namespace-scoped RBAC are garbage-collected when the AgentRun is deleted; cluster-scoped RBAC is removed when the run completes.
Examples¶
Fetch PagerDuty incidents before the agent runs¶
apiVersion: sympozium.ai/v1alpha1
kind: Agent
metadata:
name: oncall-agent
spec:
agents:
default:
model: gpt-4o
lifecycle:
preRun:
- name: fetch-incidents
image: curlimages/curl:latest
command: ["sh", "-c",
"curl -s -H 'Authorization: Token token=$PD_TOKEN' \
https://api.pagerduty.com/incidents?statuses[]=triggered \
> /workspace/context/incidents.json"]
env:
- name: PD_TOKEN
value: "your-pagerduty-token"
The agent's system prompt can then instruct it to read /workspace/context/incidents.json for current incident context.
Upload artifacts to S3 after completion¶
spec:
lifecycle:
postRun:
- name: upload-report
image: amazon/aws-cli:latest
command: ["sh", "-c",
"aws s3 cp /workspace/report.md s3://my-bucket/reports/$AGENT_RUN_ID.md"]
env:
- name: AWS_ACCESS_KEY_ID
value: "AKIA..."
- name: AWS_SECRET_ACCESS_KEY
value: "..."
Create and clean up a ConfigMap¶
spec:
lifecycle:
rbac:
- apiGroups: [""]
resources: ["configmaps"]
verbs: ["create", "delete", "get"]
preRun:
- name: create-config
image: soldevelo/kubectl:1.36
command: ["sh", "-c",
"kubectl create configmap agent-scratch --from-literal=run=$AGENT_RUN_ID"]
postRun:
- name: delete-config
image: soldevelo/kubectl:1.36
command: ["kubectl", "delete", "configmap", "agent-scratch"]
Ensemble with lifecycle hooks¶
apiVersion: sympozium.ai/v1alpha1
kind: Ensemble
metadata:
name: oncall-team
spec:
agentConfigs:
- name: triage-agent
systemPrompt: "You are an SRE triage agent..."
lifecycle:
preRun:
- name: fetch-alerts
image: curlimages/curl:latest
command: ["sh", "-c", "curl -s $ALERTMANAGER_URL/api/v2/alerts > /workspace/context/alerts.json"]
Response Gate¶
A response gate is a PostRun hook with gate: true. It holds the agent's output from reaching users until the gate explicitly approves, rejects, or rewrites it. This is useful for compliance checks, content filtering, PII scanning, or human-in-the-loop approval workflows.
How It Works¶
Without a gate, the agent's response is published to channels (Slack, Telegram, web UI) the instant the agent finishes. With a gate:
- The IPC bridge suppresses the completion event
- The PostRun Job runs the gate hook container
- The gate hook inspects the agent's output (via
AGENT_RESULTenv var) - The gate hook posts its verdict to the Sympozium API using the per-run token it was given
- The controller reads the verdict, applies it, and publishes the (possibly rewritten) response
If no verdict is written (hook fails, times out, or the hook is designed for manual approval), the gateDefault field controls behavior: "block" (default) replaces the output with an error, "allow" passes it through.
Declaring a Gated Instance¶
Add gate: true to one PostRun hook in your instance's lifecycle config. At most one PostRun hook may be a gate. The gate hook needs no Kubernetes RBAC — the controller gives it a token scoped to this one run:
apiVersion: sympozium.ai/v1alpha1
kind: Agent
metadata:
name: gated-agent
spec:
agents:
default:
model: gpt-4o
lifecycle:
gateDefault: block # or "allow"
postRun:
- name: content-filter
image: busybox:1.36 # wget is a busybox applet; curl is not
gate: true
command: ["sh", "-c"]
args:
- |
# Inspect $AGENT_RESULT, then post the verdict:
wget -q -O- \
--header "Authorization: Bearer $GATE_VERDICT_TOKEN" \
--header "Content-Type: application/json" \
--post-data '{"action":"approve"}' \
"$SYMPOZIUM_API_URL/api/v1/runs/$AGENT_RUN_ID/gate-verdict?namespace=$AGENT_NAMESPACE"
Delivering the Verdict¶
The controller injects two environment variables into the gate container, and into no other container:
| Variable | Meaning |
|---|---|
GATE_VERDICT_TOKEN |
A bearer minted for this run alone. Accepted only for this AgentRun, and only while it is PostRunning. |
SYMPOZIUM_API_URL |
The in-cluster API base URL to post to. |
That is the whole contract. The hook needs no kubectl, no ServiceAccount
token and no entry under lifecycle.rbac — a gated Agent that declares no
rbac: block gets no Kubernetes credentials in its pod at all.
Any HTTP client will do. The examples below use wget, which is a busybox
applet and so needs no extra layer; an image built on Alpine or Debian is more
likely to carry curl:
curl -fsS -X POST \
-H "Authorization: Bearer $GATE_VERDICT_TOKEN" \
-H "Content-Type: application/json" \
-d '{"action":"approve"}' \
"$SYMPOZIUM_API_URL/api/v1/runs/$AGENT_RUN_ID/gate-verdict?namespace=$AGENT_NAMESPACE"
Prefer a form that fails loudly — curl -f, or checking wget's exit code. A
POST that quietly fails leaves no verdict, and the run falls back to
gateDefault with nothing to explain why.
A verdict is capped at 32KiB, matching the truncation already applied to
AGENT_RESULT; a larger body is refused with 413 rather than silently cut.
lifecycle.rbac still exists for hooks that need the Kubernetes API for other
reasons. It is simply no longer required to report a verdict.
Network note. The gate container must be able to reach the API server Service. Nothing restricts postRun pod egress today, but if you add a NetworkPolicy covering them, allow the apiserver — otherwise every gate falls back to
gateDefault.
Treat AGENT_RESULT as untrusted: it is whatever the agent produced, and a
gate is precisely the thing that should not take it on faith.
Verdict Format¶
A verdict is a JSON object, the same on either channel — the hook's POST above, or the annotation sympozium.ai/gate-verdict set directly by a human or the API:
| Action | Effect | Fields |
|---|---|---|
approve |
Passes the original response through unchanged | {"action":"approve"} |
reject |
Replaces the response with a custom message | {"action":"reject","response":"Blocked by policy"} |
rewrite |
Replaces the response with a sanitized version | {"action":"rewrite","response":"cleaned output"} |
retry |
Hands the rejection back to the agent for another attempt — see Retrying a rejected response | {"action":"retry","reason":"npm run check failed","response":"<gate output>"} |
All actions accept an optional reason field for audit logging.
Retrying a Rejected Response¶
reject and rewrite are terminal: the run ends and the user sees the gate's
message. retry instead feeds the gate's output back to the agent as a new
attempt, so the agent can fix what the gate objected to.
This is not the Job's backoffLimit (which is disabled). That replays the pod
with an identical task and no knowledge of why the last attempt was rejected. A
retry attempt is a fresh AgentRun whose task carries the feedback.
Enable it with lifecycle.retry:
spec:
lifecycle:
gateDefault: block
retry:
maxAttempts: 3 # total attempts including the first
backoff: 30s # optional delay before the next attempt starts
maxChainTokens: 200000 # cumulative across the chain; 0 = unlimited
on: [gate] # which outcomes may trigger a retry
postRun:
- name: build-check
image: my-org/build-check:latest
gate: true
command: ["sh", "-c"]
args:
- |
if OUT=$(npm run check 2>&1); then
VERDICT='{"action":"approve"}'
else
VERDICT=$(jq -nc --arg out "$OUT" \
'{action:"retry", reason:"npm run check failed", response:$out}')
fi
wget -q -O- \
--header "Authorization: Bearer $GATE_VERDICT_TOKEN" \
--header "Content-Type: application/json" \
--post-data "$VERDICT" \
"$SYMPOZIUM_API_URL/api/v1/runs/$AGENT_RUN_ID/gate-verdict?namespace=$AGENT_NAMESPACE"
The verdict goes over the API, so response carries the build log as-is —
no annotation escaping, and no jq -Rs to wrap it in.
The successor attempt receives a structured card in place of its task:
## Retry 2 of 3
### Original Task
<the original task, with earlier retry cards stripped>
### Your Previous Attempt
<the rejected output>
### Why It Was Rejected
npm run check failed
### Gate Output
<the gate's stdout/stderr>
Gate output is clipped to 4000 characters, with the clip announced in the card.
Set SYMPOZIUM_RETRY_GATE_OUTPUT_MAX_CHARS on the controller to change it — a
test-suite dump needs more room than a lint summary.
Inspecting a chain¶
Each attempt is named <run>-retry-<n> and records its lineage in
status.attempt / status.retryOf, plus labels for querying:
kubectl get agentruns -l sympozium.ai/retry-of=my-run
kubectl get agentrun my-run-retry-2 -o jsonpath='{.status.retryOf}{"\n"}'
A superseded attempt ends in the Failed phase with
status.gateVerdict: retried and a Retried condition naming its successor. It
publishes nothing — no channel reply, no failure event — because the chain has
not finished. When the attempts or the token budget run out, the last attempt
resolves as retries-exhausted and gateDefault decides whether its output is
blocked or passed through.
What a successor inherits¶
A successor is the same run again, so it starts from the predecessor's spec and
keeps the context that says where the work came from and who is waiting on it:
the reply address of the channel that triggered it, the trace, spec.parent,
and the labels other controllers find live work by — sympozium.ai/schedule,
sympozium.ai/stimulus, sympozium.ai/request-hash, sympozium.ai/ensemble.
A schedule with concurrencyPolicy: Forbid therefore waits for the whole chain,
not the first attempt.
A caller blocked on the run it created — an OpenAI-compatible request on
/v1/chat/completions, or an MCP tool call — is answered by whichever attempt
passes the gate. The retired attempts publish nothing, and the proxy follows
status.retryOf to recognise the successor's completion as the answer to the
request it is holding.
The gate verdict annotation is deliberately not carried: a successor inheriting it would resolve against its predecessor's verdict before running.
Retrying a delegated run¶
An agent's lifecycle hooks are inherited by the children it delegates to, so a
delegate child can be gated and retried like any other run. The parent stays
blocked in its delegate_to_persona call throughout: when a child retries, the
parent's status.delegates[] entry is repointed at the successor before the
retired attempt is marked Failed, and the successor's result answers the
original delegation. The parent never sees the intermediate attempts.
An Ensemble relationships[].timeout bounds the delegation, not one attempt of
it — a chain that outlives the edge timeout is expired at whichever attempt is
running.
Why this is safe¶
The retry decision is not agent-controlled. The gate hook is an
operator-declared image in lifecycle.postRun, and the agent has no permission
to patch the sympozium.ai/gate-verdict annotation for its own run. An agent
cannot grant itself another attempt. That property is the reason retry is safe
here and would not be if the agent could self-retry.
Two further bounds on a gate that always says retry:
maxAttemptsis capped at admission by the boundSympoziumPolicy, so an operator sets the ceiling regardless of what a run requests:
apiVersion: sympozium.ai/v1alpha1
kind: SympoziumPolicy
metadata:
name: default-governance
spec:
lifecyclePolicy:
maxRetryAttempts: 3
maxChainTokenscaps the cumulativestatus.tokenUsage.totalTokensof every attempt in the chain.
Manual (Human-in-the-Loop) Approval¶
If you want a human to approve or reject each response:
- Set the gate hook to sleep indefinitely, and declare a matching
timeout— otherwise the reviewer only gets the 10-minute default beforegateDefaultdecides for them - Set
gateDefault: blockso unapproved responses are blocked - Use the web UI or API to approve or reject
postRun:
- name: manual-approval-gate
image: busybox:1.36
gate: true
timeout: 24h # how long a human has to respond
command: ["sh", "-c", "sleep 86400"]
The requireApproval toggle in the API and UI configures exactly this, with a 24-hour window.
This hook decides nothing itself — it only holds the run open — so it needs neither a verdict token nor RBAC. The human's approval arrives through the same endpoint, authenticated with the ordinary API token.
A verdict is recorded by last writer, and the endpoint accepts one only while
the run is PostRunning. In practice an approving human ends the gate: the
controller kills the gate Job as soon as it sees a verdict, and once the run
leaves PostRunning any later post is refused with 409. A gate hook that
posts in the short window before that reconcile does still overwrite the
human's verdict, so pair manual approval with a hook that waits rather than
one that decides on a timer.
In the web UI, gated runs show an amber "Approval" badge on the runs list and an approval bar on the run detail page with Approve and Reject buttons. A warning toast fires when a run requires approval.
Via the API:
# Approve
curl -X POST http://localhost:9090/api/v1/runs/my-run/gate-verdict?namespace=default \
-H 'Content-Type: application/json' \
-d '{"action":"approve","reason":"reviewed by operator"}'
# Reject
curl -X POST http://localhost:9090/api/v1/runs/my-run/gate-verdict?namespace=default \
-H 'Content-Type: application/json' \
-d '{"action":"reject","response":"Content not approved","reason":"PII detected"}'
Example: PII Scanner Gate¶
spec:
lifecycle:
gateDefault: block
postRun:
- name: pii-scanner
image: my-org/pii-scanner:latest
gate: true
command: ["sh", "-c"]
args:
- |
if echo "$AGENT_RESULT" | pii-detect --strict; then
VERDICT='{"action":"reject","response":"Response blocked: PII detected","reason":"pii-scanner"}'
else
VERDICT='{"action":"approve","reason":"pii-scanner-clean"}'
fi
wget -q -O- \
--header "Authorization: Bearer $GATE_VERDICT_TOKEN" \
--header "Content-Type: application/json" \
--post-data "$VERDICT" \
"$SYMPOZIUM_API_URL/api/v1/runs/$AGENT_RUN_ID/gate-verdict?namespace=$AGENT_NAMESPACE"
A scanner that sees the agent's raw output is exactly the container you least want holding Kubernetes credentials. With the per-run token it holds none.
Gate Status in the UI¶
| State | Web UI Indicator |
|---|---|
| Awaiting approval | Amber "Approval" badge on runs list, amber approval bar on detail page |
| Approved | Green "approved" banner on detail page |
| Rejected | Red "rejected" banner on detail page |
| Rewritten | Blue "rewritten" banner on detail page |
| Retried | Amber "retried" banner on detail page — superseded, not failed |
| Retries exhausted | Red "retries-exhausted" banner on detail page |
| Timeout/error | Red "timeout" or "error" banner on detail page |
Agent Sandbox Compatibility¶
Lifecycle hooks work with both standard Job mode and Agent Sandbox mode:
- PreRun hooks are injected as init containers into the Sandbox CR — they execute inside the gVisor/Kata sandbox.
- PostRun hooks always run as a separate follow-up Job (outside the sandbox), since the sandbox is torn down after the agent completes.
- The workspace PVC is shared between both.
Phases¶
With lifecycle hooks, the AgentRun phase transitions become:
Pending → Running → PostRunning → Succeeded (or Failed)
The PostRunning phase is only entered when postRun hooks are defined. Without them, the flow is the standard Pending → Running → Succeeded/Failed.
When a preRun hook skips the run, the flow short-circuits to the terminal Skipped phase: Pending → Running → Skipped (postRun hooks are bypassed).