Writing Orchestrator Sidecars¶
Trust model — read this before authoring or installing one
An orchestrator sidecar fully controls how the AgentRun executes. It owns the workflow loop, decides when to call the LLM, decides what to persist, and writes the run-completion signal. Because it runs with the AgentRun's identity and credentials, a malicious or buggy orchestrator can:
- Issue arbitrary LLM calls billed to the AgentRun.
- Read, exfiltrate, or tamper with anything the sidecar has access to (secrets, mounted volumes, network).
- Force the run into any terminal state, including fabricating a successful result.
- Call the LLM in any pattern it likes — including patterns that bypass the safety guarantees of the run-level
useContextpolicy.
This is a fundamentally broader trust grant than a tool-sidecar, whose actions are gated by the LLM's tool calls, the SympoziumPolicy, and the dispatch admission webhook. Only run orchestrator sidecars you have written yourself, or that come from a source you fully trust and have audited. Treat the SkillPack image reference for an orchestrator sidecar with the same scrutiny you would apply to a CI runner image that holds production deploy credentials.
This guide explains how to build a sidecar that runs in sidecar-driven mode. Unlike a tool-sidecar, an orchestrator sidecar drives the workflow end-to-end and calls the LLM as a sub-call.
If you just need to give an agent access to a CLI binary, you don't want this — see Writing Tool Sidecars for the agent-driven mode pattern.
What changes vs. tool-sidecars¶
| Tool-sidecar (agent-driven) | Orchestrator sidecar (sidecar-driven) | |
|---|---|---|
| Workflow driver | agent-runner (LLM main loop) | your sidecar |
| agent-runner mode | default (AGENT_MODE unset) |
prompt-server |
| IPC channel | /ipc/tools/exec-request-{id}.json |
/ipc/prompts/request-{id}.json |
/ipc/done written by |
agent-runner | your sidecar (in a finally block) |
| Required shim | tool-executor.sh |
none — your application code |
Everything else about the pod is the same: shared /ipc emptyDir volume, SYMPOZIUM_SKILL_PACK env, the SkillPack's secretRef / env / volumeMounts still apply.
Sidecar anatomy¶
Your orchestrator sidecar needs:
- A primary subcommand (the one named by
task.tool) that readsSYMPOZIUM_RUN_CONFIG_JSONand runs the workflow. - Zero or more helper subcommands (called internally from the orchestrator) for steps like persisting records, fetching resources, or finalising output.
- An IPC loop that calls the LLM via
/ipc/prompts/, polling for results. - A finally block that writes
/ipc/run-result.jsonand/ipc/doneso the agent-runner exits cleanly.
The SkillPack declares the primary tool and any helpers:
spec:
sidecar:
image: registry.example.com/my-orchestrator:TAG
tools:
- name: my_orchestrator_run # ← task.tool matches here
exec: ["python", "/app/orchestrator.py"]
subcommand: run
inputMode: args
- name: my_orchestrator_persist # ← called from inside run
exec: ["python", "/app/orchestrator.py"]
subcommand: persist
inputMode: stdin
The controller overrides the container's command to [exec..., subcommand] when task.tool resolves to my_orchestrator_run. Helper subcommands are invoked by your orchestrator's own code — they are not exposed to the LLM.
Reading the run config¶
The controller serialises task.parameters to JSON and sets it as SYMPOZIUM_RUN_CONFIG_JSON:
import os, json
config = json.loads(os.environ["SYMPOZIUM_RUN_CONFIG_JSON"])
# config is a dict, e.g. {"batchSize": "10", "inputDir": "/data/photos"}
Empty or nil parameters serialise to {} (not null) so your sidecar's JSON.parse returns an object the input schema can validate. The SidecarDrivenHandler enforces this.
The IPC channels¶
Three channels live on the shared /ipc volume. All writes from your sidecar should use atomic rename (write .tmp, then mv) — the agent-runner and the IPC bridge both ignore *.tmp files.
| Path | Direction | Schema | Purpose |
|---|---|---|---|
/ipc/prompts/request-{id}.json |
sidecar → agent-runner | PromptRequest |
LLM prompt request |
/ipc/prompts/result-{id}.json |
agent-runner → sidecar | PromptResult |
LLM response |
/ipc/context/clear-{id}.json |
sidecar → agent-runner | ClearContextRequest |
Reset conversation history (fire-and-forget) |
/ipc/run-result.json |
sidecar → agent-runner | free-form JSON | Final summary (surfaced on AgentRun.status.result) |
/ipc/done |
sidecar → agent-runner | empty file | Run completion signal |
Atomic writes
The agent-runner and IPC bridge ignore files ending in .tmp. Always write payload files as request-{id}.json.tmp, then mv to the final name — this guarantees the reader never sees a partial payload.
Calling the LLM¶
Write a request file with a unique id, poll for the matching result:
import os, json, time, uuid
PROMPTS = "/ipc/prompts"
def atomic_write(path: str, payload: dict) -> None:
tmp = path + ".tmp"
with open(tmp, "w") as f:
json.dump(payload, f)
f.flush()
os.fsync(f.fileno())
os.rename(tmp, path)
def call_llm(prompt: str, *, schema: dict | None = None, timeout: float = 60.0) -> dict:
"""Send a prompt to the LLM and block until the result arrives."""
request_id = uuid.uuid4().hex
request = {"requestId": request_id, "prompt": prompt}
if schema is not None:
request["schema"] = json.dumps(schema) # see "Structured output" below
atomic_write(f"{PROMPTS}/request-{request_id}.json", request)
result_path = f"{PROMPTS}/result-{request_id}.json"
deadline = time.time() + timeout
while time.time() < deadline:
if os.path.exists(result_path):
with open(result_path) as f:
return json.load(f)
time.sleep(0.2)
raise TimeoutError(f"LLM call {request_id} did not return in {timeout}s")
Structured output¶
Set schema on the request to a JSON Schema describing the shape you want back. When the provider supports it (OpenAI and OpenAI-compatible endpoints), the model is constrained to emit JSON matching the schema and the agent-runner validates the response. On Anthropic / Bedrock, the agent-runner validates the model's text output as JSON and surfaces parse failures on status="error".
schema = {
"type": "object",
"properties": {
"tags": {"type": "array", "items": {"type": "string"}},
"confidence": {"type": "number", "minimum": 0, "maximum": 1},
},
"required": ["tags"],
}
response = call_llm(prompt, schema=schema)
if response["status"] != "success":
raise RuntimeError(f"LLM error: {response.get('error')}")
tags = response["parsed"]["tags"] # validated against your schema
UseContext (run-level policy)¶
Whether the prompt is appended to conversation history or answered in isolation is set by spec.useContext on the AgentRun and surfaces as the USE_CONTEXT env var. The agent-runner reads it once at startup — your sidecar cannot override it per request. This is intentional: context isolation is a run-time policy decision, not a per-call opt-in.
Clearing context¶
To flatten the conversation history between independent iterations (one image, one record, one row), drop a clear-context request:
CONTEXT = "/ipc/context"
def clear_context(reason: str = "") -> None:
request_id = uuid.uuid4().hex
atomic_write(
f"{CONTEXT}/clear-{request_id}.json",
{"requestId": request_id, "reason": reason},
)
The agent-runner discards the conversation history accumulated since the last clear. There is no result file — clear-context is fire-and-forget.
The orchestrator loop¶
def main() -> None:
config = json.loads(os.environ["SYMPOZIUM_RUN_CONFIG_JSON"])
os.makedirs(PROMPTS, exist_ok=True)
os.makedirs(CONTEXT, exist_ok=True)
processed = 0
for record in iter_records(config):
# Cheap local pre-processing (no LLM call needed)
candidates = local_extract(record)
# Ask the LLM to make the decision
response = call_llm(
prompt=(
"Refine these candidate tags for the image. "
"Drop any that don't apply; add any that are obvious.\n"
f"Image id: {record.id}\nCandidates: {json.dumps(candidates)}"
),
schema={
"type": "object",
"properties": {
"tags": {"type": "array", "items": {"type": "string"}},
},
"required": ["tags"],
},
)
if response["status"] != "success":
log.warning("LLM error on %s: %s", record.id, response.get("error"))
continue
persist(record, response["parsed"]["tags"])
processed += 1
# Flatten context so the next record doesn't inherit this turn's history
clear_context(reason=f"after {record.id}")
return {"processed": processed}
When to clear context
Clear between every independent unit of work (one record, one row, one image). Don't clear mid-decision — the LLM may need the running history to make a multi-step choice within a single record.
Terminating the run¶
After all work is done, write the final summary and /ipc/done in a finally block:
import sys
def write_run_result(summary: dict) -> None:
atomic_write("/ipc/run-result.json", summary)
def write_done() -> None:
with open("/ipc/done", "w") as f:
f.write("done")
if __name__ == "__main__":
summary: dict = {}
try:
summary = main()
except Exception as exc:
summary = {"error": str(exc), "phase": "orchestrator"}
finally:
try:
write_run_result(summary)
finally:
write_done()
sys.exit(0 if "error" not in summary else 1)
The agent-runner observes /ipc/done (not /ipc/run-result.json) as the completion signal. If you skip the finally, an exception in your workflow leaves the agent-runner polling forever.
Why the finally matters
Earlier sidecar-initiated designs wrote /ipc/done from the agent-runner as soon as it saw /ipc/run-result.json. If your orchestrator was mid-prompt↔LLM call when this happened, the new /ipc/done caused promptLLM to return "observed before result arrived", which misclassified successful records as failed and routed them to an unreliable fallback branch. Writing /ipc/done from your finally block guarantees there are no in-flight prompts when the signal arrives. See Sidecar-Driven Mode for the full rationale.
tool-executor.sh does not apply¶
Tool-sidecars bridge /ipc/tools/exec-request-*.json using a small shell script (tool-executor.sh). Orchestrator sidecars do not use this script — your application code drives the workflow and talks to the LLM directly via /ipc/prompts/.
You can still declare helper subcommands (like my_orchestrator_persist in the example above) in spec.sidecar.tools[] if you want the agent-runner or another sidecar to be able to invoke them — see Writing Tool Sidecars → Native Sidecar Tools. Helpers called only from inside your orchestrator's own code don't need to be declared on the SkillPack, but declaring them keeps the contract explicit.
Environment summary¶
Your orchestrator sidecar runs with:
| Env var | Source | Purpose |
|---|---|---|
SYMPOZIUM_RUN_CONFIG_JSON |
controller | JSON-marshalled task.parameters (always a JSON object — see Sidecar-Driven Mode → Parameter passing) |
SYMPOZIUM_SKILL_PACK |
controller | The SkillPack name (for logging) |
USE_CONTEXT |
controller | "true" if spec.useContext is unset/true; "false" if explicitly disabled. Read by the agent-runner only. |
| (your declared env vars) | spec.sidecar.env[] |
Per-deployment config |
| (your secrets) | spec.sidecar.secretRef |
Mounted at secretMountPath |
Common pitfalls¶
| Symptom | Cause | Fix |
|---|---|---|
| agent-runner never exits | orchestrator threw before writing /ipc/done |
Wrap the main work in try/finally; the finally must always write /ipc/done |
LLM call returns status: "error" with parse message |
Schema was set but provider is Anthropic/Bedrock | Either drop the schema (free-form text response) or post-validate the response yourself |
| LLM seems to remember previous record's decision | Missing clear-context between iterations | Call clear_context() after each independent unit of work |
| Sidecar exits early with no result | Helper subcommand not declared on SkillPack | Declare it in spec.sidecar.tools[] if it's invoked across the IPC boundary |
JSON.parse throws on the orchestrator's first action |
SYMPOZIUM_RUN_CONFIG_JSON is unset |
Always guard: if "SYMPOZIUM_RUN_CONFIG_JSON" not in os.environ: ... |
See also¶
- Sidecar-Driven Mode — the controller/handler view of the same flow
- Adding a Task Mode — extend Sympozium with a new dispatch mode
- Writing Tool Sidecars — the agent-driven mode pattern (tool-executor.sh, native sidecar tools)
- Skills & Sidecars — SkillPack CRD structure, RBAC