Workflows¶
A workflow runs a model-authored Python script that fans out subagents over deterministic control flow. Intermediate results stay in script variables and never re-enter the conversation — only the script's returned string does — so a wide fan-out (a 40-file review, a multi-source audit) is distilled to one answer instead of drowning the main context.
Why a workflow, not many task calls¶
The task tool delegates one job and returns its summary into
context. For a loop or a wide fan-out that is two problems: the orchestration
logic lives in the model's head (brittle), and every child's prose lands back in
context (token volume). A workflow fixes both — the control flow is real Python,
and the reduction over the children's output happens in the script. This is why
it is scoped to read / analysis fan-out (review, audit, research): the win is
distilling volume, not mutating the workspace.
The workflow tool¶
The model calls workflow with a script (Python defining async def main())
and an optional one-line goal. The tool is ASK-kind: you see and approve
the generated script before it runs. That approval is the human-in-the-loop
review of model-authored code and the guard against a wide fan-out's surprise
token burn.
The script runs in a closed namespace (no imports, no file or eval access —
capability-by-absence, not a sandbox). main() must return a string; that
returned string is the only thing that re-enters the model's context.
A workflow script is orchestration, not general computation — if the model
needs to actually run Python (data crunching, quick experiments), that's the
OS-sandboxed run_code interpreter, a
separate subprocess with a real kernel-enforced boundary.
The seven primitives¶
Inside main() the script holds exactly these seven injected primitives:
| Primitive | What it does |
|---|---|
await agent(prompt, opts) |
Run one subagent to completion; returns its prose — or a validated dict when opts carries a schema (see Structured output). opts is a dict that may set label (the agent's name in the transcript card — defaults to a truncation of the prompt), agentType (a profile), model (a per-call override), schema (a JSON Schema dict), isolation: "worktree" (run an EDIT agent in its own git worktree — see Worktree isolation), and publish: "<name>" (write the result to the run's artifact store and return a {ref, summary} handle instead — see The artifact store). |
await parallel([thunk, ...]) |
Gather agent thunks at a barrier. A failed child maps to None; a cancellation propagates (Esc tears the whole run down). |
await pipeline(items, *stages) |
Run each item through the stage chain independently — no barrier between stages (the wall-clock win). A per-item stage error drops that item to None. |
publish(name, data) |
Write data to the run's artifact store (sync); returns the workspace-relative path. A dict/list is stored as JSON, anything else as text. |
ref(name) |
The path of an artifact this run already published (sync); an unpublished name is a clear error. |
phase(title) |
Telemetry (sync): mark a new phase. Updates the /workflows panel and emits a transcript breadcrumb. |
log(message) |
Telemetry (sync): append a log line (kept to the last ~200). |
Example main()¶
async def main():
phase("collect")
files = ["auth.py", "session.py", "tokens.py"]
# Fan out a read-only review over each file, in parallel. `label` names
# each agent's row in the transcript card (else it's a prompt truncation).
reviews = await parallel([
lambda f=f: agent(
f"Review {f} for auth bugs. Return one finding per line, or 'none'.",
{"agentType": "researcher", "label": f"review {f}"},
)
for f in files
])
phase("distill")
findings = [r for r in reviews if r and r.strip() and r.strip() != "none"]
log(f"{len(findings)} files had findings")
if not findings:
return "No auth issues found across the reviewed files."
return "Auth review:\n\n" + "\n\n".join(findings)
Ask each child for a delimited string (one finding per line) and reduce over it
in the script — or, for typed data, use a schema (below) and get a real dict.
Structured output (agent(schema=...))¶
Pass a JSON Schema as opts["schema"] and agent() returns a validated
dict instead of prose:
async def main():
schema = {
"type": "object",
"properties": {
"severity": {"type": "string", "enum": ["low", "med", "high"]},
"findings": {"type": "array", "items": {"type": "string"}},
},
"required": ["severity", "findings"],
}
r = await agent(
"Review auth.py for auth bugs.",
{"schema": schema, "agentType": "researcher"},
)
# r is a dict — reduce over it with normal Python, no string parsing.
if r["severity"] == "high":
return "HIGH: " + "; ".join(r["findings"])
return f"{r['severity']}: {len(r['findings'])} findings"
The dict lives in the script's variables and is reduced over in code — it never
re-enters the model's context on its own (only main()'s returned string
does, as always). The script can subscript it, branch on it, and aggregate
across many agents before returning one distilled string.
How it works — multi-turn, then structure. The subagent runs its normal
loop first (its real analysis work, with its tools and reasoning). When that loop
ends, the harness makes one extra structuring call that asks the child to
emit its final answer in the schema's shape, then validates the result with
jsonschema. A schema-violating result, or a child that can't produce
conformant output, surfaces as a clean workflow error the model can read and
retry — it is never silently coerced to a string.
Built for the models kin actually runs. kin's real traffic is non-Claude serves (vLLM/Qwen, z.ai/GLM, MiniMax) over both wires, so structuring does not use any Anthropic-only output mode. It uses a portable two-tier mechanism:
- Forced tool call — a synthetic
structured_outputtool whose input schema is your schema, withtool_choiceforced to it (Anthropic{type:"tool"}/ OpenAI{type:"function"}). On vLLM a forcedtool_choiceis grammar-constrained server-side on both wires (probed on vLLM 0.23.0 —research/probe_strict_decoding.py), so the arguments are schema-valid by construction; the OpenAI-compat wire also ships a strict-normalized schema +strict: trueso OpenAI-proper-class endpoints enforce too (see Strict decoding). Thejsonschemavalidation above still runs on the result — the server guarantee makes it near-never-fire; it doesn't replace it. - Prompt-JSON fallback — if a serve doesn't honor forced
tool_choice, the child is re-asked for only a JSON object matching the schema; the first JSON object is leniently extracted (tolerating markdown fences / surrounding prose), validated, with one bounded retry. This floor works on any model.
The artifact store (publish / ref / handles)¶
A wide fan-out often produces more than the orchestrator needs to hold: a
lane's full structured findings matter to the final writer, not to the control
flow. The run-scoped artifact store externalizes that data — files under
<workdir>/.kin/artifacts/<run_id>/ — so the script (and follow-up subagents)
work with paths and summaries instead of payloads. This is the
lead-keeps-context pattern: the orchestrator reasons over a digest; only the
subagent that needs the full result reads it.
Two ways in:
publish(name, data)writes an artifact and returns its workspace-relative path (e.g..kin/artifacts/<run>/draft-r1.md). Embed that path in a later prompt and the subagent reads it withread_file— the prompt carries a pointer, not the payload.ref(name)looks the path up again later. Re-publishing a name overwrites (the revise-loop pattern).agent(prompt, {"schema": ..., "publish": "lane-1"})— the opt-in handle path. The subagent's result is published for you and the call returns{"ref": <path>, "summary": <digest>}instead of the raw result. The summary is the dict's ownsummaryfield when the schema has one (else a compact preview), capped at ~2,000 chars. Withoutpublish,agent()returns the prose / validated dict directly, exactly as before.
async def main():
# Lanes publish full findings; the script holds only handles.
handles = await parallel([
lambda f=f, i=i: agent(
f"Analyze {f}. `summary` = one paragraph; `evidence` = path:line facts.",
{"schema": lane_schema, "publish": f"lane-{i + 1}", "agentType": "explorer"},
)
for i, f in enumerate(files)
])
digest = "\n".join([
f"- {h['summary']}\n full findings: {h['ref']}"
for h in handles if isinstance(h, dict)
])
# The writer reads a lane's FULL artifact only when its summary isn't enough.
return await agent(
"Write the report from these lane digests; read a lane's artifact with "
"read_file when you need the detail.\n\n" + digest,
{"agentType": "explorer"},
)
Semantics worth knowing:
- Run-scoped, not a cross-session blackboard. Each run gets its own directory; nothing is shared between runs or sessions. On resume, the cached raw result is re-published under the resuming run's own directory, so handles never point at a stale run.
- Names are validated, strictly. 1–64 chars of letters/digits/
._-, starting with a letter or digit — no slashes, no... A bad name is a clear workflow error (rejected, never silently rewritten): a name is a handle the script refs back by, so it must round-trip exactly. - Failures stay raw. A crashed subagent's error prose is returned as-is —
never published, never wrapped in a handle — so
isinstance(h, dict)checks still catch it. - Doesn't mix with
isolation: "worktree". A worktree agent runs in a fresh checkout that doesn't contain the run's artifacts — artifact refs are for the read/analysis fan-out on the shared workdir.
The bundled /revise and /research
commands are the shipped consumers: /revise publishes each draft so graders
read it by ref; /research lanes return {ref, summary} handles the
synthesizer expands on demand.
Worktree isolation (parallel edit)¶
The MVP workflow is for read/analysis fan-out — distilling volume, not mutating
the workspace — because many parallel agents editing the one working copy would
collide. isolation: "worktree" lifts that limit for edit agents: each runs
with its workdir set to its own fresh git worktree, so concurrent edit agents
touch disjoint trees.
async def main():
files = ["auth.py", "session.py", "tokens.py"]
# Each agent edits in its OWN worktree — no collisions on the shared tree.
results = await parallel([
lambda f=f: agent(
f"Add type hints to {f}. Edit the file in place, then commit.",
{"isolation": "worktree", "agentType": "coder", "label": f"types {f}"},
)
for f in files
])
return "Edited " + ", ".join(files)
How it works and what to expect:
- Created from
HEAD. The worktree isgit worktree add -b kin-wf/<run>/<n> <tmp> HEADon a unique branch per agent (no shared-branch lock). It checks out HEAD, so an edit agent does not see your uncommitted working-tree changes — commit (or stash) first if the agents need them. - Clean → removed, dirty → kept for review. When an agent finishes, a clean
worktree (no changes) is removed (worktree + branch + temp dir). A dirty one
is kept: its path and branch are appended to the agent's result and
surfaced via
log(), so you can inspect, diff, and merge the work. kin never silently discards an edit agent's changes. - Best in auto mode. Edit confinement — edits running with no per-call
prompt and writes kernel-confined to the worktree — is only enforced in
auto mode. Outside auto, the worktree still isolates
the tree, but each edit goes through the normal approval gate (and isn't
sandbox-confined); the workflow logs a one-line note saying so. Because N
parallel edit agents would otherwise pile N approval modals onto the one prompt,
running an
isolation: "worktree"edit workflow in auto is the intended path. - Concurrency cap. At most
WORKFLOW_MAX_PARALLEL(6) worktrees exist at once — the worktree lifecycle lives inside the fan-out permit, so the parallel cap bounds the checkouts (and their index locks) too. - Non-git workdir → no isolation. If the workspace isn't a git repo, the agent simply runs unisolated (the feature degrades, it doesn't error).
The common gitdir is writable under the sandbox
A linked worktree's git internals (its index/HEAD and the shared
objects/refs) live outside the checkout, so in auto mode the OS sandbox is
given a matching write-allow for those paths — otherwise a sandboxed
git add/commit inside the worktree would fail. The code-execution surfaces
(hooks/, config) stay denied, same as for a normal repo.
Resume (within-run, by cache)¶
A killed or edited workflow can be resumed: pass a prior run's id as
resume_from_run_id and every completed agent() call replays from that run's
journal instead of re-dispatching a subagent — only new, changed, or unfinished
calls run live.
# The model re-issues the workflow tool with the prior run's id; the harness
# hydrates the cache from that run's journal before main() runs.
workflow(script=<same or edited script>, resume_from_run_id="<the prior run id>")
When a run is interrupted (Esc) or errors with at least one completed agent, the
harness surfaces a system note naming the run id so the next attempt can replay
it. The card and /workflows panel mark replayed agents (↺ cached, N replayed)
so you can see at a glance what was reused versus run fresh.
How the cache decides hit vs miss:
- Keyed by structural call path, not call order. Each
agent()result is keyed by where in the script it ran — aparallelbranch index, apipelineitem:stageposition (nesting composes), plus a per-position occurrence — not by a global "Nth call" counter. This is load-bearing forpipeline: its per-item stages finish in a run-varying order, so a sequence index would attach a cached result to the wrong call on resume. The structural path is stable across runs, so each cached result maps back to the exact call that produced it. - Content hash prevents stale hits. The key also carries a hash of the
inputs that determine the result — the prompt,
agentType,model, andschema. Edit any of them at the same position and the call misses (re-runs with the new inputs) instead of returning a stale wrong answer. - Cached agents must be side-effect-free. Only read / analysis /
schemaagents are cached. An editing agent should useisolation: "worktree"— and a worktree (side-effecting) agent is never cached or journaled (a cache hit wouldn't reproduce its edits), so it re-runs on every resume. A subagent that crashed is also never cached (resume re-runs it, rather than replaying the failure). - Dict results round-trip. A
schemaagent's validateddictis journaled and replayed as a realdict(the script still subscripts it on resume).
Within-run only
Resume-by-cache is a within-run checkpoint, not cross-session history.
Cross-session resume is already automatic — a whole workflow serializes as a
single tool_use + tool_result, so --resume brings back its result with
the rest of the conversation. resume_from_run_id is the orthogonal "don't
redo the expensive fan-out I already half-finished" lever. The journal lives
beside the session journal (<project_dir>/<run_id>.workflow.jsonl).
In the transcript¶
A running workflow renders as a single live card, not the flat stream of its subagents' internal tool calls. The card shows the run and hides the tool noise:
⚙ workflow · summarize each harness file ⠹ running · 9s
phase ✓ Scan › ⠹ Summarize › · Synthesize
◇ events.py — the event vocabulary ✓
◇ session.py — lifecycle + subagent spawn ✓
◇ loop.py — the agent loop ⠹
◇ subagents.py — profiles + caps · queued
log loop.py: 3 sections summarized
4 agents · 2 done · 1 running · 1 queued · 9s
- The phase trail shows done (
✓), current (animated spinner), and upcoming phases — the upcoming titles are pre-parsed from the script'sphase("…")literals, so the remaining steps read at a glance before they run (a dynamically-built title the parse missed simply joins the trail when its event arrives). - Each agent's row carries a status glyph —
✓done, spinner running,· queued,✗error — coloured by the role palette (cyan for running, green for done, coral for error, muted for queued). A wide fan-out caps the visible rows at 8 (live rows always keep a slot) and folds the rest into an… +N doneoverflow line pointing at/workflows. - The
logline is the latestlog(…)breadcrumb — a one-line narrator; the/workflowspanel keeps the full history. - The header's right side tracks the run with an animated braille spinner
and live elapsed time (
⠹ running · 9s→✓ N agents · M phases · {elapsed}s, or✗ failed). The spinner respects reduced motion (Textual'sanimation_level), holding a static glyph when animations are off. - Clicking anywhere on a running card opens the
/workflowspanel for the full detail.
When the workflow finishes, the card swaps to the distilled string main()
returned and folds the agent rows under a ▸ agents expander:
⚙ workflow · summarize each harness file ✓ 4 agents · 3 phases · 14s
<the distilled string main() returned>
▸ agents
The /workflows panel below is the other view — a polling overlay you can open
mid-run (or click through to from the card) to see every run at full detail.
Commands¶
| Command | Argument | What it does |
|---|---|---|
/workflow |
<goal> |
Nudge the model to accomplish a goal as a workflow — it sends a turn instructing the model to write an async def main() orchestration and call the workflow tool. |
/workflow-save |
<name> |
Save the most recent successful run's script as a reusable /<name> command (below). |
/workflows |
Open the read-only runs panel — one row per run this session. | |
/ultracode |
[on\|off] |
Toggle session-wide auto-orchestration (below). Bare flips it; on/off set it explicitly. |
/workflow <goal> is a normal kind: prompt command: the body is sent as a user
turn with $ARGUMENTS substituted, so the model still has to author and (via the
ASK gate) get your approval for the script.
Save a run as a reusable /command¶
Once a workflow has run cleanly, /workflow-save <name> persists its script to
<workdir>/.kin/commands/<name>.md as a kind: workflow
command. Re-invoking /<name> <text> re-runs that exact script — no re-authoring
by the model.
- It still runs through the real
workflowtool path. Every invocation passes the same ASK approval gate (you review the script) and the same AST filter, so a saved workflow is no more privileged than its original run — it can't become a trust hole by being saved. <text>reaches the script as theargsstring global, not via$ARGUMENTSsubstitution. The text is injected as data (astrnamedargs), never spliced into the script source — so it can't smuggle code past the structural filter. Read it like a variable:files = args.split(). It defaults to"".
kin ships five bundled examples out of the box. /critique <target>
(five-dimension review: correctness, edge cases, security, performance,
maintainability) and /security-review <target> (six-dimension security
audit with attack-path disclosure) both dispatch three parallel read-only
reviewer subagents over disjoint rubric subsets and synthesize findings by
severity.
/deep-research <question> runs the full web
research loop — plan, parallel researcher lanes, adversarial gap review, a
citation-liveness gate, and a cited report. /research <question> is its
local sibling: a general codebase/repo fan-out that scales effort to
the question's complexity (1 explorer lane for a simple lookup, up to 5
for a complex survey), gives each lane a clearly-scoped brief, and
synthesizes from {ref, summary} artifact handles.
/revise <task> runs a rubric-graded revise loop — draft, three parallel
graders (accuracy / completeness / clarity, each reading the published
draft by ref), targeted revision, at most two bounded rounds. The scripts
are fixed, so every invocation dispatches the same subagents the same way
with no improvisation between runs.
/workflow review every file changed on this branch, one finding per line
… (you approve the generated script; it runs; you like the result)
/workflow-save review-branch
… (later, on another branch:)
/review-branch src/kin/harness/loop.py src/kin/harness/session.py
Ultracode mode¶
/workflow nudges the model once, for a single task. /ultracode on makes it
the default for the whole session: kin will reach for a workflow on its own
for substantive multi-step work — anything with volume, natural parallelism, or
repeated verify-then-fix cycles — instead of only when you ask. /ultracode off
(or a bare /ultracode to toggle) turns it back off; a ⬡ ultracode badge on the
status bar shows when it's on, and the state survives /resume.
It changes propensity, not autonomy. The guidance rides the per-turn
<system-reminder> channel (so it works on every wire/model and never touches the
cached system prompt), and the workflow tool stays ASK-gated — you still
review and approve every script before it runs. Ultracode is a human-only
control, like the permission mode: the model has no tool to flip it.
The /workflows panel¶
/workflows opens a polling overlay (the same shape as the Ctrl+O agent
panel) that reads the live run state from the session once a second — clicking
a running transcript card opens it too. The top list shows one row per run:
the status (running / done / error / cancelled), the goal, the current phase,
the agents run so far (and how many are in flight), elapsed seconds, and the
phase count. Below it, a detail pane follows the highlighted run (Up /
Down pick one) with the full picture the transcript card caps: the whole
phase history, every agent row with its status, and the log tail. It is
read-only for now — there is no kill surface; Esc closes it.
Pressing Esc during the turn tears the whole run down.
Caps and safety¶
The harness caps every run so a wide fan-out can't spiral:
| Limit | Value | Meaning |
|---|---|---|
| Max parallel | 6 (WORKFLOW_MAX_PARALLEL) |
Concurrent subagents in flight, sized on a dedicated host session's semaphore (the normal task budget is untouched) |
| Max agents | 64 (WORKFLOW_MAX_AGENTS) |
Hard total-agent ceiling per run — the real runaway guard (the parallel cap bounds concurrency, not the aggregate) |
| Run timeout | 600s (WORKFLOW_TIMEOUT) |
Cooperative wall-clock bound on the whole script |
| Stall watchdog | 3s (WORKFLOW_STALL) |
Busy-loop / non-cooperative-script watchdog |
Security posture:
- ASK approval — you review the model-authored script before it runs.
- Closed namespace — no imports, no file/
evalaccess; the script can only use the seven injected primitives (publish/refreach exactly one strictly-validated directory under.kin/artifacts/, nothing else). - Read/analysis scope — the MVP is for fan-out that distills information (review, audit, research), not workspace mutation.
- No nesting — a workflow cannot be called from inside a subagent (a
depth-
>0guard, and the tool is scoped out of subagent registries), so the per-run caps stay meaningful.
Note
Each subagent a workflow spawns still inherits the session's permission mode, so any destructive tool call it makes goes through the normal gate. The workflow's own approval covers the script, not a blanket grant for the children.