Scenario 1 — Job-and-Exit: the Price Reporter
Prerequisite: Anatomy of an Agent.
Reference implementation: the
workeragent —agents/go-worker, whose template lives beside it atagents/go-worker/template.json(seeded byscripts/seed.sh). It is the simplest agent on the platform and exercises exactly this shape.
The personality
The Price Reporter spins up, asks the sidecar for a scoped token, calls a
protected price API for a watchlist, formats a short summary, emits it as a
lifecycle event, and exits cleanly. It holds no state, serves no traffic, and
has no Service. When its main() returns, the pod exits 0 and the operator
marks the workload Completed.
This is the canonical “do one job and disappear” agent — the right shape for report generation, a one-shot data pull, an ETL step, or any task with a clear beginning and end.
Shape of the code
A single main() that runs top to bottom and then returns. No server, no loop.
import { postEvent, TokenClient } from '@spawnly/sdk';
const agentId = process.env.AGENT_ID ?? 'unknown';const registryUrl = process.env.REGISTRY_URL ?? 'http://registry:8080';const tenantId = process.env.TENANT_ID ?? 'default';const apiUrl = process.env.API_A_URL ?? 'http://sample-api-a:8080';const sidecarUrl = process.env.SIDECAR_URL ?? 'http://localhost:8089';
// The SDK's TokenClient talks to the local sidecar's /token endpoint. It handles// the sidecar-not-ready retry (it binds :8089 slightly after the container// starts) and caches tokens per scope, so your code just asks for a scope.const tokens = new TokenClient(sidecarUrl);
async function main() { await postEvent(registryUrl, agentId, 'reporter_started', { agentId });
// 1. Acquire a scoped token for the protected price API. const token = await tokens.getToken('sample-api-a:read');
// 2. Call the protected API — pass the tenant header. const res = await fetch(`${apiUrl}/work`, { method: 'GET', headers: { Authorization: `Bearer ${token}`, 'X-Tenant-ID': tenantId }, }); const body = await res.json().catch(() => ({}));
// 3. Emit the result as a lifecycle event (this is what the dashboard shows). await postEvent(registryUrl, agentId, 'price_report', { status: res.status, ok: res.ok, report: body, });
// 4. Return. The process exits, the pod completes, the workload -> Completed. console.log('[price-reporter] done');}
main().catch(async (err) => { await postEvent(registryUrl, agentId, 'agent_error', { error: err instanceof Error ? err.message : String(err), }); process.exit(1);});The snippet above is TypeScript for readability, but the deployed worker
reference is Go: agents/go-worker/main.go
runs the same token → protected-call shape on the Go SDK
(github.com/spawnly/sdk-go). Its AuthenticatedClient attaches the Bearer
token and X-Tenant-ID for you, so the core is just:
tc := spawnly.NewTokenClient()client := spawnly.NewAuthenticatedClient(sampleAPIURL, scope, tc, spawnly.WithTenantID(tenantID))
// Authorization + X-Tenant-ID are injected by the client; relative path resolves against sampleAPIURL.resp, err := client.Post(ctx, "/task", body)The same TokenClient + protected-call + postEvent sequence is the deterministic
callApiADirect() step in
agents/parent-agent/src/index.ts — the
parent agent does this exact thing before it orchestrates its child.
Optional: add an LLM summarisation step
If the report benefits from natural-language summarisation, build a Flue context
and prompt the model once, then instrumentFlue so the turn shows up on the
dashboard. The child agent’s execute() in
agents/child-agent/src/index.ts is a
minimal example of a one-shot session.prompt(...). For a pure data job, skip
the LLM entirely — a job-and-exit agent does not need one.
The template
No lifecycle field — it defaults to short-lived, so the operator marks the
workload Completed on exit and creates no Service. This is the worker template
from agents/go-worker/template.json, renamed:
{ "agentType": "price-reporter", "version": "1.0.0", "status": "active", "meta": {"displayName": "Price Reporter", "description": "Pulls prices, reports, and exits"}, "runtimeSpec": { "image": "agent-price-reporter:latest", "resources": {"cpuLimits": "500m", "memoryLimits": "256Mi"}, "envDefaults": {"LOG_LEVEL": "info"} }, "authzTemplate": { "spiceDbRelations": [ {"resource": "tenant:{{tenant_id}}", "relation": "agent", "subject": "agent:{{agent_id}}"} ] }}The authzTemplate grants tenant:T#agent@agent:X, which is what lets the
sidecar mint a token the price API will accept (the API checks the SpiceDB
work_on permission before serving the request).
Run it (using the seeded worker)
The worker template is already seeded and is functionally this agent, so you
can run the exact flow today:
make demo # port-forwards orchestrator :8080 + dashboard :8090, spawns a worker
# …or do it by hand:curl -sf -X POST http://localhost:8080/spawn \ -H 'Content-Type: application/json' \ -d '{"agentType":"worker","tenantId":"tenant-1","userId":"user-1","task":"price snapshot"}'# -> {"workloadName":"worker-xxxxx"}
# Watch it reach Completed:kubectl get agentworkload worker-xxxxx -w
# Inspect the event timeline (your price_report event appears near the end):curl -sf http://localhost:8080/v1/agents/worker-xxxxx/events | jqOn the dashboard (http://localhost:8090) the agent appears, walks through
registration → token → API call → your price_report event → agent_completed,
and then the pod is gone.
What this scenario teaches
- The minimal viable agent: token → protected call → event → exit.
- Short-lived completion semantics (
lifecycleomitted ⇒ Completed on exit0). - That an agent needs no Service and no LLM to be useful.
Next: keep the agent alive across many jobs in Scenario 2 — Loop-until-stopped.