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
trigger
telegram_inbound · payload {chat_id, text}
routes to
telegram-allocation · always → telegram-responder
why thin
Adapters 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
running
telegram-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 · brief
The 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 · inbox
Dual-write to loom_inbox with kind=flow_dispatch, to_actor=<target agent>, payload_memory_id pointing at the brief.
3 · NOTIFY
A Postgres trigger emits loom_events. kit-loomd, a separate daemon, LISTENs on that channel. The architecture is event-driven, not polled.
4 · match
kit-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 · spawn
The 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 reads
The 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 acts
Does 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 · complete
Calls 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 · cascade
If 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.

poll
telegram-poll daemon long-polls Telegram, normalises the message, POSTs to /work-item-triggers/telegram_inbound/fire with idempotency_key tg:<update_id>
allocate
telegram-allocation spawns. Its decide step returns {flow_slug: "telegram-responder"}. End-action spawn_flow fires.
classify
telegram-responder evaluate step (system, claude-sonnet-4) reads the conversation, returns JSON: {should_build: true, build_brief: "...", ack: "On it"}
decide
The 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
dispatch
agent-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
work
Forest reads the brief, locates the Whiskerwool repo, edits about.html, runs vercel --prod, captures the deploy URL
return
Forest calls kit_flow_complete with {summary, commit_sha, changed_files, deploy_url}. flow_engine records the output, advances to end_after_build
close
end_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).