How a thought becomes a work item, runs as a flow, and gets executed by agents
A message arrives. A trigger fires. A flow spawns. An agent picks up a step, reads the brain over MCP with a scoped key, does the work, and reports back. This is the substrate that ties Telegram, Studio, the brain, and Kit's facets into one moving system.
drawn from the live code · routes/work_item_triggers.py · services/flow_engine.py · services/loom/daemon.py · services/agent_registry.py · routes/loom.py
start · the trigger point
system step · an inline LLM or static decision
decision · branches by condition
agent step · dispatched to a Kit facet
end · terminal node with optional end-actions
1
The core idea · a work item IS a flow instance
There is no separate "tasks" model any more. When Telegram routes a message, when a deep-research memory lands, when an operator types in Studio's quickadd, the same thing happens: a flow definition is instantiated, a row is written, and Studio shows it as a visible work item moving through states.
Flow definition
A graph: nodes (start, system, decision, agent, end) plus edges with optional conditions. Versioned per slug. Draft → publish promotes to active; the prior active becomes archived.
flow_definitions · flow_nodes · flow_edges · flow_node_end_actions
Flow instance
One run of a flow. Carries the trigger payload that birthed it and the running step_outputs as each step completes. State: running · complete · errored · cancelled. This is what Studio renders as a work item.
flow_instances · current_step_ids · step_outputs · trigger_payload
Attempt
One execution of one step within an instance. Carries agent_name, started_at, completed_at, output. A step can have multiple attempts (rework). The dialog's activity log is the per-step attempt list.
flow_instance_attempts · state ∈ {pending, running, done, errored}
Why this collapses task and flow: the lifecycle table (StudioTask + Attempt + Round) used to be the visible substrate. The cutover (memory #121773) made flow_instance the canonical work item: Studio reads from flow_instances now. Anything that needs to look like work in Studio has to be a flow instance.
↓
2
Triggers · how the outside world enters the substrate
A trigger is a registered slug with a source kind and an allocation flow. Anything that wants to create a work item POSTs /work-item-triggers/{slug}/fire with a small JSON payload. The trigger doesn't run the work; it routes.
The registered triggers (today)
telegram_inbound
studio_quickadd
coding_request
self_improvement_from_research
Each declares: source_kind (telegram, schedule, kit_noticed, …), allocation_flow_slug, concurrency (single | multiple), idempotency strategy.
work_item_triggers · POST /{slug}/fire
How a trigger fires
Adapter normalises a native event (Telegram update, cron tick, MCP call, button click) into a generic payload. POSTs to /work-item-triggers/{slug}/fire with an idempotency key. The handler dedupes, then spawns the allocation flow.
routes/work_item_triggers.py:fire_trigger
Idempotency
Three layers: ingestion key (same key returns the same instance), singleton concurrency (only one in-flight allowed per trigger), and allocation-time dedupe inside the flow. Telegram uses tg:{update_id}; deep research uses research-{memory_id}.
layer 1: trigger_id + idempotency_key unique
What an adapter looks like: the Telegram poll daemon (scripts/telegram-poll) calls Telegram's getUpdates, normalises each new message, then POSTs to telegram_inbound. The deep-research script (scripts/deep-research.py) writes its research memory, then POSTs to self_improvement_from_research. Every adapter does the same shape: ingest, normalise, POST.
↓
3
Allocation flows · routing thin, target flows do the work
A trigger fires an allocation flow (always lightweight: one decide step). The allocation chooses which target flow to spawn and what payload to pass. The target flow is the real work item.
Allocation flow · the routing decision
3 nodes only: start → decide → end. The decide step is a system step (static or LLM). The end node carries a spawn_flow end-action that creates the target instance.
start
→
decidesystem
→
endspawn_flow
triggertelegram_inbound · payload {chat_id, text}
routes totelegram-allocation · always → telegram-responder
why thinAdapters stay generic. Routing logic lives in the flow graph, editable in Studio without code changes
Target flow · the real work
N nodes. Whatever shape the task needs. The trigger payload becomes instance.trigger_payload; each step writes into step_outputs; later steps read upstream outputs via dotted paths (e.g. evaluate.reply).
start
→
evaluatesystem · llm
→
intent
→
builderagent
→
endsend_telegram
runningtelegram-responder, agent-build, self-improvement-from-research, coding-workflow
What spawn_flow actually does: an end-action of type spawn_flow takes a flow_slug and an optional trigger_payload (with @step.field sigils that resolve against the parent's outputs). It creates a new instance and advances it. The child runs in its own DB session (fire-and-forget) so a slow child can't poison the parent's transaction.
↓
4
What a flow is made of · five node kinds, two end-action types
Every flow definition uses the same primitives. Once you can read the five node kinds and the end-action vocabulary, you can read any flow in the system.
start
The entry point. Carries no logic. Each flow has exactly one. The trigger payload arrives here.
type = "start"
system step
An inline LLM call (with optional Kit context injection) or a static output. The engine runs it and merges the result into step_outputs.
type = "step" · subtype = "system"
decision
Picks the first outgoing edge whose condition evaluates true against step_outputs. Conditions are short expressions like evaluate.should_build == True.
type = "decision"
agent step
Halts the flow, writes a brief into memories and the inbox, then waits for an agent to call kit_flow_complete. See band 5.
type = "step" · subtype = "agent"
end
Terminates with an outcome label. Can carry an ordered list of end-actions that fire as the instance closes.
type = "end" · outcome_key
End-action · send_telegram
Sends a Telegram message as the instance closes. Pulls text and chat_id by dotted path against the namespace (so evaluate.reply and trigger.raw.chat_id become real values).
action_type = "send_telegram"
End-action · spawn_flow
Spawns another flow instance. Used by allocation flows, and by any flow that wants to chain into another (e.g. self-improvement-from-research spawns agent-build as the proposal auto-dispatches).
action_type = "spawn_flow"
The namespace, in one line: conditions and templates resolve against step_outputs[step_id].field, trigger.raw.* (or whatever the parent passed), and counter_values. That's everything an edge or an end-action can see.
↓
5
Agent dispatch · how a step becomes a spawned agent doing work
When advance() reaches an agent step, the flow doesn't keep running. It writes a brief and waits. The Loom daemon picks the brief up, decides which agent should run it, spawns that agent, and the flow stays paused until kit_flow_complete arrives.
1 · briefThe engine writes a memories row (category=handoff) titled "Flow step · <slug> · <step title>". Body includes the brief, the upstream step_outputs, the trigger_payload, and a kit_flow_complete call template.
2 · inboxDual-write to loom_inbox with kind=flow_dispatch, to_actor=<target agent>, payload_memory_id pointing at the brief.
3 · NOTIFYA Postgres trigger emits loom_events. kit-loomd, a separate daemon, LISTENs on that channel. The architecture is event-driven, not polled.
4 · matchkit-loomd walks its agent_subscriptions table. Each row is (event_kind, matcher_name, target_agent, spawner_name). The matcher picks subscriptions whose target equals the brief's to_actor.
5 · spawnThe matched spawner runs. spawn_claude_exec for Forest, spawn_codex_cli for Taz, spawn_ollama for Ember, spawn_inbox_only for UI-only routes. The spawn is a subprocess with the brief's prompt and an MCP server attached.
6 · agent readsThe spawned agent connects to its Kit over MCP with a scoped key. Calls kit_onboard, kit_recall, kit_read to load context. The scope on its key bounds what it can see in the brain.
7 · agent actsDoes the work. Edits files, runs commands, drafts memories. Most actions touch the local filesystem and the operator's tools; some touch other Kits via federation.
8 · completeCalls kit_flow_complete(instance_id, step_local_id, output) via MCP. The engine records the output on the attempt row and advances the flow to the next halt point.
9 · cascadeIf the next step is also an agent step, repeat. If it's a system step or an end, the engine runs it inline. Most flows finish without any human in the loop.
The "at-desk" routing fork (in the architecture but currently disabled): when the target agent is inbox-capable and a live instance is present on the matching surface, Loom can write a loom_inbox row instead of summoning a fresh subprocess. The active path is always spawn; the inbox path is reserved for restoring the agent-to-agent live channel when needed.
↓
6
Scoped knowledge · what the agent can see while it works
An agent never gets the whole brain. The MCP key it spawns with carries a scope: a set of projects (and optionally knowledge areas) it is allowed to read. Every kit_recall the agent runs respects that scope.
Key on spawn
The spawner reads the api_key associated with the agent's facet (Forest's claude-code key, Taz's codex key) and sets it in the spawned subprocess's environment so the MCP client uses it.
spawn_claude_exec · spawn_codex_cli
Scope check on every call
Each MCP tool (kit_recall, kit_read, kit_write) calls auth.resolve_key, gets an AuthContext, and applies allowed_projects / allowed_areas as a WHERE clause. No tool can return rows the key cannot see.
services/auth.py · routes/memories.py
Brief sets the lens
The brief itself (in the handoff memory) typically narrows further: "look at project X, today's research only". The scope is a ceiling; the brief is the floor.
_compose_brief in flow_engine.py
What agent A sees
dev-alex spawns with allowed_projects = [comunity-dynamic-ui, kit]. kit_recall returns rows from those projects, plus cross-project rows.
project: kit
project: comunity-dynamic-ui
scope: work
What agent B sees
dev-sam spawns with allowed_projects = [comunity-dynamic-ui]. Same query, different visible knowledge. SimVida memories don't appear.
project: comunity-dynamic-ui
scope: work
Why this matters for the demo: two developers with the same task brief, on the same Kit substrate, will produce different work because they can recall different memories. The substrate stays one source of truth; the agent's view is shaped by the key. Project scoping is the production answer to "how does Kit serve a team without leaking between members".
↓
7
A complete trace · one Telegram message, end to end
Pulling all six bands together. The operator types "Add a footer to the Whiskerwool about page" into Telegram. Here is what the substrate does, in order, with no human in the loop.
polltelegram-poll daemon long-polls Telegram, normalises the message, POSTs to /work-item-triggers/telegram_inbound/fire with idempotency_key tg:<update_id>
allocatetelegram-allocation spawns. Its decide step returns {flow_slug: "telegram-responder"}. End-action spawn_flow fires.
classifytelegram-responder evaluate step (system, claude-sonnet-4) reads the conversation, returns JSON: {should_build: true, build_brief: "...", ack: "On it"}
decideThe intent decision routes to end_ack_and_dispatch. End-actions run in order: send_telegram fires the ack; spawn_flow fires agent-build with build_brief passed through
dispatchagent-build hits its builder agent step. flow_engine writes the brief memory + loom_inbox row. kit-loomd matches inbox_dispatch_to_forest, spawns Forest via spawn_claude_exec with the operator's MCP key
workForest reads the brief, locates the Whiskerwool repo, edits about.html, runs vercel --prod, captures the deploy URL
returnForest calls kit_flow_complete with {summary, commit_sha, changed_files, deploy_url}. flow_engine records the output, advances to end_after_build
closeend_after_build's send_telegram end-action sends builder.summary to trigger.chat_id. The operator sees: "Done. Added footer to about.html. Commit dac4895. Live at https://…"
Two memories left behind in Studio: the telegram-responder instance (with the ack and the build_brief in its step_outputs) and the agent-build instance (with the summary, commit, and deploy URL). Both visible in the timeline. Both clickable. Both end up in flow_instances as durable, queryable rows. Recall later: "what did I ask Kit to do on Whiskerwool last Wednesday?" finds these.
The shape that makes this work:
work item IS the flow instance (one model, one source of truth) · the trigger normalises every adapter into the same POST (Telegram, MCP, cron, webhook all funnel through one endpoint) · flow graph is editable in Studio (logic without code) · agent steps halt the engine and resume on kit_flow_complete (no polling) · MCP key scope flows through every recall an agent makes (knowledge always bounded by who's looking) · the brain is fed back via Kit's own writes (every flow leaves a memory trail in memories & the agent's return handoff).