Skip to content

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:

  1. Forced tool call — a synthetic structured_output tool whose input schema is your schema, with tool_choice forced to it (Anthropic {type:"tool"} / OpenAI {type:"function"}). On vLLM a forced tool_choice is 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: true so OpenAI-proper-class endpoints enforce too (see Strict decoding). The jsonschema validation above still runs on the result — the server guarantee makes it near-never-fire; it doesn't replace it.
  2. 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 with read_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 own summary field when the schema has one (else a compact preview), capped at ~2,000 chars. Without publish, 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 is git worktree add -b kin-wf/<run>/<n> <tmp> HEAD on 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 — a parallel branch index, a pipeline item:stage position (nesting composes), plus a per-position occurrence — not by a global "Nth call" counter. This is load-bearing for pipeline: 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, and schema. 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 / schema agents are cached. An editing agent should use isolation: "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 schema agent's validated dict is journaled and replayed as a real dict (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's phase("…") 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 done overflow line pointing at /workflows.
  • The log line is the latest log(…) breadcrumb — a one-line narrator; the /workflows panel 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's animation_level), holding a static glyph when animations are off.
  • Clicking anywhere on a running card opens the /workflows panel 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 workflow tool 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 the args string global, not via $ARGUMENTS substitution. The text is injected as data (a str named args), 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/eval access; the script can only use the seven injected primitives (publish/ref reach 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->0 guard, 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.