Automation architecture
One engine runs work: the workflow engine (packages/platform-domain/src/workflows/,
apps/api/workflow/) — an n8n-style graph of nodes on an infinite canvas (/workflows), with
one universal item shape on every edge, named outputs, triggers drawn as nodes, and a per-node run
view. Crons, event triggers, the Run button, the one-off internal chains and every card handed to
NEO all run here.
The runs control plane is retired (2026-09-25). It ran coding and local cards on the USER’s
machine: the platform queued a runs row and viite agent watch claimed it. It went with those two
card kinds, runners, teammates and the orchestrate beat — a card is now handed to NEO, and a
squad does the work on the platform, coding included (see “NEO and squad kinds”). The decision
record is tasks/3d8-assign-main-agent.md.
There is no longer a third engine either. The Job engine — Job<T>, processJob, two runners, the
job-workflow queue, /api/jobs/* and both tables — is deleted. Its two capabilities already
existed as nodes, it ran the same step instances through the same AI gateway, and it survived only
because three dashboard call sites still POSTed to it. Those three now enqueue ad-hoc workflow
runs; see “One-off runs” in Part 1. The decision record is
tasks/spike-reduce-engines.md.
When touching a node, this doc is the reference — do not re-derive it from code.
Part 1 — The workflow engine
Section titled “Part 1 — The workflow engine”Why a second engine, not an extension
Section titled “Why a second engine, not an extension”The old engine’s two hardest problems were artefacts of its own shape system. Steps communicated
through a shared mutable WorkflowContext — items plus a metadata bag of side channels
(enrich.rows, read.sourceMessageIdsByAdapter, tagExclusions) that no edge could draw and no
validator could derive rules from. That worked for a straight line and cannot work for a graph: two
branches sharing one mutable context clobber each other, and a side channel is invisible on a
canvas. The rewrite deletes the shape system rather than generalizing it.
One item on every edge
Section titled “One item on every edge”interface WorkflowItem { /** Identity in the source system — Gmail message id, Notion page id. */ externalId?: string; /** Where it came from, for provenance and tag-style write-backs. */ source?: { scheme: string; host?: string }; /** The record. What conditions read, what expressions address. */ json: Record<string, unknown>;}ReadItemRef and DispatchRow collapse into conventional keys inside json (title, url,
summary, tags, rawContent, …). Any node can connect to any node; validity becomes “does this
node find the fields it needs?”, answered at run time with the missing field named — the n8n
trade, taken deliberately, and the reason the run view has to be good. Source readers flatten their
metadata alongside the envelope fields (a condition reads {{ $json.subject }}, not a nested bag),
writing url/contentType last so a source can never shadow them.
Graph, nodes, named outputs
Section titled “Graph, nodes, named outputs”interface WorkflowNode { id; type; name; params; position; runOnFailure? }interface WorkflowEdge { from: { node, output }; to: { node, input } }A node implements INode.execute(items, params, context) → NodeResult, whose outputs is keyed by
output name — { main: [...] } for most, { true: [...], false: [...] } for the If node,
{ actionable, dismissed } for triage. Named outputs are what made If/Else expressible at all, and
they replace every side channel: what a step could only count, a node routes. enrich emits
main/declined, analyze main/dropped, triage actionable/dismissed,
collect_invoices invoices/rejected, best_of_tech main/picks. An item a node could NOT
judge (a thrown call, a degraded decider) leaves on neither output — it reaches no downstream
tag, stays unlabelled at the source, and is re-read next run. That is the old tagExclusions
retry contract, structural instead of a set of ids in a bag.
A NodeResult also carries halt (success: true = clean early stop of this branch;
false = fail the run), usage (LLM spend, reported not appended — the old slice arithmetic
charged one fork for the other’s tokens), watermarks (cursor advances, committed only if the
whole run succeeds), incomplete (a cap bit, so a partial run cannot pass for a complete one) and
signalSamples (per-item condition verdicts for the run view).
Node descriptors (NodeDescriptor) are the design-time half: category
(trigger/ingest/enrich/decide/act), mode (deterministic/ai/hybrid — drives the credits gate and
the badge), outputs, inputs (an InputFieldSchema map the inspector renders), emits
(declared json keys, the field picker’s pre-first-run half), skillable.
The executor — runWorkflow (apps/api/workflow/engine/)
Section titled “The executor — runWorkflow (apps/api/workflow/engine/)”Kahn-ordered walk with three load-bearing semantics:
- A node with no live input is SKIPPED, not run empty. Run it empty and a
dispatchon a condition’s untaken branch logs “no rows”, returns success, and every branching run reports work it never did — the silent-green-run failure the old shape system existed to prevent.skippedis a first-class outcome (never rendered red), with a recordedskipReason(no_input/upstream_skipped/upstream_failed/branch_halted/cancelled). Consequence worth teaching users: an edge means “and use what came out”, not merely “and then” — a self-sourcing node chained after an empty read skips; to say “do both”, fan out from one parent. runOnFailureclosers run whenever the pass did not reach them. A node flaggedrunOnFailureowns a subject’s terminal write (update_link,update_contact). It executes with an empty batch andcontext.runErrorset in TWO cases: after a failure (the run stays failed), and at the end of a completed run that skipped it forno_input/upstream_skipped— the case a green run used to strand:read_urlRAN and fetched nothing,enrichskipped, the closer skipped behind it, and the link sat onenrichingwith a 142 mscompletedrun behind it. TherunErrorthen carries the nearest empty-handed ancestor’s own sentence (“Fetch the page: Fetched 0 pages (1 failed: https://…)”), so the card names the cause. A closer behind a deliberately halted branch stays skipped — the halt already gave its verdict. Itsskippedrow is replaced by theranrow, so the run view shows one row that says it happened.- Deferred effects. Usage is charged per node as it completes; watermarks accumulate and commit
only on a fully successful run, so a mid-graph failure leaves the source cursor untouched and the
batch is retried. Idempotency across those retries is the import ledger (below), backed by the
source’s own guard where it has one (Gmail’s
-label:<processed>exclusion).
The NodeRegistry maps type → executor, fail-fast like the old StepRegistry: an unregistered
type stops the run rather than skipping. Registration is environment gating
(buildNodeRegistry in apps/api/workflow/buildWorkflows.ts): a node whose integration is not
configured is never registered, never offered by GET /api/workflows/nodes, and the canvas cannot
draw it. The work nodes share the SAME dependencies the old steps use — one ingestion API, one
watermark store, one connector registry, one decider factory, one task repository, one dispatch
target map, one digest reader set — handed over by buildWork, so the two engines cannot disagree
about credentials, cursors, or rows.
Durability — checkpoints, resume, and parking (shipped 2026-09-18, verified in production)
Section titled “Durability — checkpoints, resume, and parking (shipped 2026-09-18, verified in production)”The executor can now be re-entered. Three mechanisms, one shape.
Checkpoints. Each completed node’s FULL outputs are written to workflow_node_outputs
((run_id, node_id) primary key). Deliberately not a bigger sample: a sample is for DISPLAY and
caps at 3 items / 8 KB, dropping what does not fit; a checkpoint is for REPLAY, so it is stored
whole or not at all. fitCheckpoint refuses an oversized, non-serializable or NUL-bearing output
and the node’s row says so — the run still completes, it simply cannot resume past that node.
Checkpoints are written AFTER the node’s side effects (written first they would claim work a crash
one line later prevented) and discarded when the run reaches a terminal state.
Resume. On boot, a run left non-terminal by a dead process is RESUMED from its checkpoints where it has any, and only written off where it has none. A replayed node is not re-executed — its stored outputs are fed back into the graph and its row reads “Restored from an earlier attempt — not re-run.” Reviving a run with no checkpoints would restart it from node one, which for a chain that already sent an email is a repeat, not a resume.
Parking. A node returns park: WaitSpec and the run becomes waiting: alive, holding no
queue slot and no process, with waiting_until / waiting_signal / waiting_node_id on the row.
waiting is neither in-flight nor terminal, and is deliberately excluded from
workflow_runs_inflight_idx — counting a parked run as in-flight would make the
skip-if-previous-still-running guard refuse every future fire of a workflow whose last run is
asleep. A timed park books a pg-boss job with startAfter; a signal park is woken by
wakeOnSignal. A park with NEITHER is refused, at the node and again at the executor: it would
leave a row alive, advancing on nothing and surfaced by nothing.
A re-entrant park (park.reenter: true) is for a node that must look at the world AGAIN when it
wakes — is CI finished yet? An ordinary park is checkpointed and replayed, so a node can never poll
across one. A re-entrant park is deliberately NOT checkpointed: the woken pass finds no replay entry
and executes the node again with the same (replayed) inputs; whatever it returned beside the park is
discarded. It still needs a timer or a signal, and the node bounds its own polling from the domain —
await_checks stops 30 minutes after the commit it is watching, a fact every pass can read without
keeping state.
The publish node uses the same primitive for an Instagram Reel, which Meta processes
asynchronously. The publisher stages the container and DEFERS (PublishDeferred). The node commits
the card back to reviewed, due a few seconds later, with the container on
payload.mediaContainer, and then parks { until: earliest deferral, reenter: true }. The woken pass
finds the card due again, wins the usual atomic reviewed → posting claim and resumes the same
container. post_comment sits after publish, so the Reel’s first comment waits for the Reel. The
bound is the container’s age (30 minutes), held by the publisher. An Instagram carousel defers
the same way, first on its item containers (mediaContainer = { id: '', children }, no parent
yet) and then on the CAROUSEL container. No graph changed: cloned “Publish
scheduled posts” workflows get this without an update. Details:
Connect an Instagram account.
The wait node is deliberately thin — it names a condition and passes its items through. It cannot
sleep: the executor walks its graph in one synchronous pass under a per-node deadline, so a node
that blocked for seven days would be killed long before it woke. Waiting is the executor’s job.
A resumed run RE-RESOLVES its actor rather than trusting the org its message carried. That message was written when the run parked, perhaps days ago, and membership can change while a run sleeps — replaying a stored actor would let a run keep acting with access its owner no longer has.
What this does NOT give you. Exactly-once. A node acts, then checkpoints; a crash between the
two re-runs it. Every node receives context.idempotencyKey — stable (runId, nodeId) across
attempts — for exactly this. PublishNode’s conditional-UPDATE claim remains the model where the
side effect is ours.
Verified in production 2026-09-20: a run parked, held through a container replacement mid-park, then woke 0.6 s after its scheduled time, replayed both completed nodes without re-running them, and carried the restored item into the node after the wait.
Concurrency — per process, and pinned in production
Section titled “Concurrency — per process, and pinned in production”registerWorkflowRunWorker fetches a batch and runs it with bounded parallelism, capped globally
(WORKFLOW_RUN_CONCURRENCY, default 4) and per org (WORKFLOW_RUN_ORG_CONCURRENCY, default 2) so
one org cannot starve the rest. The counters live in process memory, so these are per-container
caps: with N containers the ceiling is N times the cap, and nothing coordinates between them. A
cross-process cap needs a shared lease in Postgres.
Production is deliberately pinned at WORKFLOW_RUN_CONCURRENCY=1 so the durability work landed
without also changing how much runs in parallel. Raising it is a separate, deliberate step.
The skip-if-previous-still-running overlap guard is untouched: it asks hasRunning(workflowId)
and prevents two runs of ONE workflow racing the same export target, which is unaffected by running
two DIFFERENT workflows side by side. Concurrency is across runs; the guard is within a workflow.
Squads — delegation is a park with a token
Section titled “Squads — delegation is a park with a token”A spawn_squad tool call does not start a nested agent. It enqueues an ordinary ad-hoc child
run and parks the parent on a signal the child sends when it settles. There is no new execution
mechanism: a squad is wait with a different token, and the settling child is the first caller of
wakeOnSignal.
- The model supplies only the squad’s kind, the objective, for coding the repo, and for content the person’s skill ref (resolved against the run’s own user only). The kind picks the graph — and with it the squad’s token budget; depth, user, org and timeout come from the run doing the spawning, so a model asking for more depth or budget is describing fields never read.
- Depth is capped at 1 where the child is built, not in the prompt. Main spawns squads; squads spawn nothing.
- Every park carries a timer. A squad run is ad-hoc, and an ad-hoc run whose booked wake is lost would otherwise strand its parent if its wake were ever lost.
- A wake only resumes a run that is still asleep. The squad’s signal usually wakes the parent
first, so the timer booked at park time fires on a run that has already finished. A
resumedelivery whose row is notwaitingorqueuedis dropped (stale_wake). Before this guard (2026-09-28), that stale timer re-ran NEO from its first node. NEO spawned a new squad and booked a new timer, and one card re-ran its coding squad every hour until the credits ran out. - A parent that spawned several squads parks on ONE cohort token naming all of them, and is woken only once every sibling is terminal — waking on the first would hand it a partial picture, once per child. A missing member counts as settled (it can never arrive); a parked one does not (it is still working).
collect_squads is what makes the downstream half usable: it reads the spawnedSquads ids the
spawning node emitted, loads those runs, and emits ONE item carrying every outcome plus a summary
string ready to drop into the next agent’s goal. One item rather than one per squad, because the
consumer is a prompt and an expression reaching $node['…'].json would otherwise see only the
first. A squad that had not finished (the parent woke on its timeout rather than its cohort) is
named as unfinished and marks the node incomplete, so a run that woke early cannot pass for a
complete one.
The model that spawns a squad does not see its result. The reasoning loop runs inside one node
execution, and a node cannot pause halfway. On wake the node is REPLAYED, not re-executed —
re-executing would re-ask the model, which might decide something else and orphan the squad that
just ran. So results flow DOWNSTREAM: the spawning node emits the child ids it waits for, a node
after it reads those children, and reasoning over them is a second agent node. Our multi-agent
system is graph-shaped, not a chat loop pretending to be one.
Delivering a signal by hand — POST /api/workflows/signals/:token
Section titled “Delivering a signal by hand — POST /api/workflows/signals/:token”The other sender. Until this existed only the engine could deliver a signal (a squad waking its
parent), so a wait node parked on a domain token like invoice:1043 could only ever end on its
timer.
Authenticated, and scoped by ownership of the run, not knowledge of the token. Unlike a webhook
token, a signal token is deliberately guessable: it is derived from the domain so a sender can name
it without first asking the run what it called itself. Treating it as a secret would be treating an
address as a password. wakeOnSignal takes the actor and skips every run the caller does not own —
creator, or a colleague in the same org; a run with NO org stays its creator’s alone.
Engine-owned prefixes are refused (squad:, squad-cohort:, approval:), and the refusal
lives in the domain because the invariant does: squad-cohort: means “wake when the LAST sibling
settles”, and hand-delivering it would resume a parent on a partial picture. No amount of ownership
makes that correct.
A signal carries no payload. A woken run rehydrates from its checkpoint and never reads the
request that woke it, so accepting a body and dropping it would be a lie the caller could not see.
Nobody waiting answers 200 {woken: 0}, not 404 — an early or late signal is a legitimate outcome,
and the count is how a caller tells “delivered” from “nobody was there”. A run skipped for ownership
counts as nobody.
The token budget — what one run may spend (shipped 2026-09-28)
Section titled “The token budget — what one run may spend (shipped 2026-09-28)”A step cap does not bound cost: every step of an agent loop resends the whole conversation. On 2026-09-28 a coding squad’s planner, capped at 20 steps, read the repository at up to 100k tokens a step until the managed Cline balance ran out at step 8. A run’s spend is now bounded by tokens (input + output, every AI node together), in three places:
- On the graph, set by whoever builds it.
WorkflowGraph.budgetTokens— never on the message, never from the model. The graph is what every wake path carries (a booked wake re-sends the message; the boot sweep rebuilds it from the row’s stamped graph), so a budget kept anywhere else was lost on the first park.RUN_TOKEN_BUDGETS(domain, beside the squad templates) is the one table: NEO 50k, research 200k, coding 600k (plan, work, up to two CI fixers, review), workflow 100k, content 300k (the worker reads an article and writes one back). The card chains (tts,link,contact) have no agent loop and no budget; saved workflows have none either — a budget there is unbounded unless a graph declares one. - Between nodes, by the executor.
runWorkflowtotals each node’susageand refuses to START the next node once the budget is spent: the run fails with the node named, and itsrunOnFailureclosers still run (NEO’slog_activitywrites it onto the card). On a resume, replayed nodes keep the usage they reported on the earlier pass —executeWorkflowRunreads it off the run’s row before the pass overwrites it — so a park does not reset the budget. - Inside a node, by the agent loop. Each node gets
tokenBudgetRemaining;AgentNodehands the loop the tighter of that and its owntokenBudgetparam. A squad’s Plan node has its own (PLANNER_TOKEN_BUDGET, 100k): it runs first, so without one it could spend the worker’s share reading.
A cap ends in an answer, not mid-read. When the next step is projected to cross the budget (the
last step’s input plus its growth), or it is the last step the step cap allows, the loop takes that
step with toolChoice: 'none' — the model must write its answer with what it has read. That is what
fixed the EMPTY plan the first live coding squad handed its worker (2026-09-27), which had been
worked around by raising the planner to 20 steps; the planner is back to 8, as a backstop to its
token budget. A loop stopped this way reports stopReason: 'budget' (or 'max_steps') — a cap, not
a failure, so the gateway does not fail it over.
The tool policy — a gate in front of every tool call
Section titled “The tool policy — a gate in front of every tool call”IToolPolicy (domain) returns allow, deny(reason) or needs_approval(reason), and AgentNode
consults it before every handler. It exists because spawn_squad was capped on DEPTH at the enqueue
and on fan-out nowhere: a model emitting thirty of them in one loop got thirty child runs, each
spending credits. maxSteps does not bound that — it counts model STEPS, and one step may carry
many parallel tool calls.
The default policy caps calls per tool per node execution: three for spawn_squad (the
“Main + 3 parallel squads” shape), one for create_workflow, three for create_post_draft, three for update_task (the board squad’s
write that can publish or archive), twenty-five for anything else, so
the bound is a property of the policy rather than of which tools someone remembered to list.
- A deny is handed BACK to the model as the tool’s result, never thrown. A refused call should make the model choose something else, which it can only do if it is told.
- The policy is pure and stateless — the count arrives on the call, so one instance built at boot is safe across every run. It is sync on purpose: it runs inside the model’s loop, and a policy needing org configuration should be BUILT from it rather than query per call.
priorCallscounts only ALLOWED calls, so a deny does not consume the allowance it refused.- A decision is not an authorization. Every handler still re-derives its own tenancy. Deleting the policy makes the agent unbounded, not insecure.
needs_approvalholds the call for a person — only in a node that can ask one.
Human approval (shipped 2026-10-09)
Section titled “Human approval (shipped 2026-10-09)”The default policy answers needs_approval for update_task that publishes (status: reviewed) or archives — but only when the call says the node is approvable, which AgentNode
sets when its approvals param is on and a store (IToolApprovalStore, table
workflow_approvals) is wired. Every other node is told it cannot ask anyone and the call goes
through exactly as before approvals existed. The board squad’s worker is the one node that has it on.
In that node the call is HELD, not run: it becomes a pending ToolApproval, the model is told
it waits for the person (and it still counts towards the tool’s cap), and once the loop is over the
node parks on approval:<runId>:<nodeId> with a timer of APPROVAL_WAIT_MS (3 days). The card
lists the held calls (GET /api/tasks/:id/main/approvals, naming the card each acts on) and decides
them (POST …/approvals/:approvalId {decision}, the card’s creator only, one conditional
pending → approved | rejected). The last decision wakes the run — the approval: prefix is
engine-owned, so the public signal route refuses it and this route calls wakeOnSignal itself.
Option A (“park carrying the proposal; on resume execute the approved call first”) cannot work here:
checkpointNode runs before the park block, so on wake the agent node is REPLAYED and never runs
again. So the calls run downstream: apply_approved (always registered, because the board
graph names it; without a store it passes its item through) expires what is still pending, runs
the approved calls as the run’s actor through the same in-process MCP tools, records each outcome,
and emits approvalSummary. The squad’s reviewer and describeSquadOutcome (→ NEO’s write-up)
read it, because the worker’s own text predates the decision. NEO’s park on a board squad is
APPROVAL_WAIT_MS + 30 min, so it waits the person out instead of writing up a squad that is only
waiting for an answer.
NEO and squad kinds — assigning a card is the action (shipped 2026-09-25)
Section titled “NEO and squad kinds — assigning a card is the action (shipped 2026-09-25)”Handing a card to NEO starts it. There is no Run button: picking NEO in the assignee picker
writes assignedAgent: 'main', the event-publishing task repository publishes
task.handed_to_main on the TRANSITION to main (a write repeating main fires nothing), and
subscribeHandToMain (apps/api/tasks/usecases/handToMain.ts) enqueues the chain and notes the
hand-off on the card’s timeline. Because the event comes from the repository, every writer is
covered — the board, a captured link, a new contact (both born assigned to NEO), a tts card — and
MCP shares that repository. To run a card again, take it back and hand it over again. A run that
cannot start (no AI channel) leaves the assignment saved and says why on the timeline.
NEO classifies; it never does the work. Its only tool is spawn_squad. It reads the card
(renderMainAgentPrompt, which surfaces a repo:owner/name tag and a skill:<slug> tag on their own
lines; with no repo: tag, the defaultRepo on the owner’s github:// channel fills the repository
line), rephrases the
intent into one objective, and picks a squad kind (SQUAD_KINDS, SquadTemplate.ts):
NEO’s context pack (loadNeoContextPack, neoContextPack.ts): NEO’s system prompt ends with
what it knows about the person beyond the card. It is built at hand-off, with no model call, and
holds:
- their self contact and company cards, with applied org facts only (a run never reads a private fact);
- the last 30 days per module (
DrizzleNeoActivityReader): their own board, posts and runs, plus the org’s CRM, invoices and sent emails as counts; - the memories the person kept (their own, and the org’s) — see Learned memories below;
- their other open cards on NEO;
- for a linked
github://repository, the lesson titles intasks/lessons/INDEX.mdand the##headings ofCLAUDE.md.
It is capped at 6,000 characters and framed as data, not instructions. A section that fails, or takes
longer than 4 s, is left out. No pack is built for a card that owns its squad. The MAIN node sets
cacheSystem, so VercelAiReasoner sends the system prompt with Anthropic’s cacheControl, and NEO’s
later steps re-read it at the cached rate (usage.cacheReadTokens).
| Kind | Graph | Picked by |
|---|---|---|
research | plan (planner) → work (worker, scrape, read_doc, recall, recall_read) → review (reader). Given a repo (“explain how X works in our code”), the worker reads that repository instead of the web: recall, recall_read, repo_list_files, repo_read_file, read_doc — no scrape next to private code, no write tool | NEO |
coding | plan (repo read tools, recall, recall_read) → work (repo tools, opens ONE draft PR) → CI ⇄ fix, at most 2 fixes → review | NEO |
workflow | work (create_workflow, saved switched off) | NEO |
content | plan (planner, no tools) → work (worker, scrape, get_task, list_destinations, create_post_draft, recall, recall_read) → review (reader) | NEO |
board | work (worker, the viite MCP tools BOARD_SQUAD_TOOLS: list_tasks, get_task, search_contacts, list_destinations, update_task, log_activity, link_records, plus the local recall, recall_read and propose_memory) → review (reader) | NEO |
image | one generate_image node named Work, prompt = the objective (NEO writes it as the picture: subject, style, mood). To redraw or restyle a picture the card shows (“this photo as a pencil sketch”), NEO also passes spawn_squad’s reference, which becomes the node’s referenceImageUrl (read through the SSRF-guarded CardImageLoader.readImage, as the run’s actor). A reference is accepted only when it is one of the card images NEO was SHOWN (CardImages.sources, listed by URL on the prompt’s Images: line) — a URL NEO read anywhere else is refused, and so is every reference when the model could not read the pictures. No agent loop and no budgetTokens — a platform-key node, balance-gated and charged per image. It never writes onto a card: it reports the picture as produced.images (RunResult.images, own /api/assets/… URLs only), which NEO’s timeline line draws inline with a Use this image button. The button calls the editor’s own Illustrate write (useOfferCardImageTarget): a post’s or a link’s payload.image on click, a blog cover or a task’s <img> into the draft its Save sends — offered only where that card’s Illustrate is, and never applied without the click. Without the managed Cline key generate_image is not registered, and spawn_squad refuses image with IMAGE_SQUAD_UNAVAILABLE | NEO |
tts | tts | the card |
link | read_url → enrich → update_link; a Google Maps URL takes maps_place → update_link instead (the place, read off the share link’s redirect, filed as a company card — a contact for a practitioner — keyed on Google’s place id) | the card |
contact | find_linkedin_profile → read_contact_mail → enrich_provider → enrich_contact → update_contact | the card |
company | read_company_site → enrich_provider(subject: company) → enrich_company → update_company | the card |
recall is the squads’ search over what the workspace already knows (apps/api/recall). It is
opt-in and read-only, and it searches as the person who asked (readerOf). It covers:
- cards (title, words, CRM payload, tags);
- every timeline entry;
- applied org facts — never an
ownerfact; - the person’s repository memory:
CLAUDE.md,tasks/*.md,tasks/lessons/*.mdof their defaultgithub://repository; - memories a person kept (never a proposal).
A hit returns excerpts and its id; recall_read returns that document’s full indexed text (in
20,000-character windows) through the SAME visibility predicate the search ranks with, so an id the asker
could not have been shown reads as not found. Every squad that has recall has recall_read.
RecallIndexer keeps recall_documents in step every 10 minutes:
- set-based upserts of changed rows only (
content_hash); - repository files re-read at most every 6 h per person;
- Voyage
voyage-3.5-liteembeddings whenVOYAGE_API_KEYis set.
A query runs two legs:
- words: Postgres full-text, an OR of prefix terms over
to_tsvector('simple', …); - meaning: pgvector cosine.
The legs are merged by reciprocal-rank fusion, then followed 1–2 hops over task_links. Visibility is
applied inside every leg, before ranking: card rows go through DrizzleTaskRepository’s own
visibleTo, and repository rows are their owner’s alone. A deleted card leaves the index by
ON DELETE CASCADE. With no key, recall answers by words and says so.
Learned memories (apps/api/memory, table memories, task_9bd2481e): what NEO remembers about
a person or their organisation between cards (“LinkedIn posts in French”). An agent never writes an
applied memory — a memory steers every later run, so one an email, a page or a paste wrote would be a
standing prompt injection.
- Proposing: NEO and its write-up hold the opt-in
propose_memorytool (as does theboardsquad; at most 3 per run); Claude has the MCPremembertool;POST /api/memories/importparses a pasted Claude / ChatGPT export by code (no model), one proposal per line. All of them holdIMemoryProposalsonly — no status, no scope, no decide — handed over throughproposalsOnly, so the methods are absent at run time too. - Deciding:
POST /api/memories/:id/accept|dismiss,DELETE /api/memories/:idand a person’s ownPOST /api/memoriesare session-only (authMiddleware, never an API key) and JSON-only. Only the person a proposal belongs to decides it; keeping it for the whole org (scope: org) needs an org admin. A dismissed text is never proposed again (unique per org, person andmemoryTextKey). - Reading: the review digest (
loadReview’smemories, the inbox entry), the context pack (applied only, after the profile), andrecall(kindmemory: personal ones for their person, org ones for every member of that org). A person’s memories go with the person (ON DELETE CASCADE).
NEO knows its world, and never reads the board itself. Its prompt says the person’s todo list,
posts, contacts and deals ARE viite records, and that it must never ask for something a squad can look
up. A card that points at a record by description (“the first X post in my todo list”) goes to the
board squad with that description kept word for word, and the squad resolves it. NEO was
deliberately NOT given the board read tools: card bodies, ticket subjects, scraped link summaries and
contact bios are written by other people, and NEO is the one model that can start a coding squad
with the owner’s GitHub token. The board squad reads that text instead. It has no repository tools,
cannot spawn, is told card text is data, and its update_task is capped at three calls per turn. An
ambiguous match changes nothing and lists the candidates. Found live on 2026-10-06, when NEO asked
twice where a todo list lived instead of publishing a post from it.
A tts, link, contact or company card has exactly ONE squad it can go to, so squadSpecForCard picks it and
the chain starts it through a deterministic delegate node — no NEO model call to confirm the
obvious. Both shapes share one tail:
card-owned: delegate ──────────────────→ collect_squads → summarise → log_activityotherwise: NEO → did it delegate? ─true→ collect_squads → summarise ─┐ └false (its one question) ────────────┴→ log_activityA card handed to NEO is a conversation — a chain of runs (shipped 2026-10-09). On the false
branch the log_activity node marks NEO’s line meta.mainQuestion (its meta param, applied only
when metaUnlessRan: 'Write up the squad' did not run, and never on a failure line). The card page
shows a pending question at the top of the Content tab, and a composer that is always there on the
Activity tab (MainComposer.tsx). POST /api/tasks/:id/main/reply takes a message at any time —
not only after a question — logs it as a note with meta.replyToMain, and takes the next turn
(continueMainConversation): NEO starts at once when it is idle; when it is busy the message is
marked queued and the END of that run starts the next one (subscribeMainFollowUp, not after a
Stop). One start per card: a per-process lock plus the queued row withQueuedRun writes before the
enqueue returns.
A reply cannot resume the SAME run: a graph is acyclic and a signal carries no payload, so a
conversation is the chain of NEO runs since the hand-off. The assignment’s line carries
meta.mainHandOff (and the run it started); each run’s start line carries meta.seenSeq, the last
timeline entry its prompt read — the follow-up compares entry numbers, never startedAt, which is
when a worker picked the run up and can trail the prompt by a minute. NEO reads the whole
conversation since the hand-off (mainConversation, newest kept within 8 000 characters). One cap,
MAIN_CONVERSATION_TOKEN_BUDGET (1.5 M), covers every run of the conversation and the squads they
started (makeConversationSpend); each NEO run gets min(RUN_TOKEN_BUDGETS.main, what is left),
and a spent conversation starts no run and says to reassign. The route takes a signed-in person
only: an API key (and so MCP) cannot hand NEO free text.
The board’s NEO view and web push (shipped 2026-10-09). /tasks?scope=neo lists the
caller’s own cards on NEO in three sections, classified on the server
(GET /api/tasks/main/overview → classifyMainCard, ordered by compareMainCards):
- needs you: a pending
ToolApprovalon the latest NEO run or its squads, or an unanswered question that run asked. Longest waiting first. - working: the latest run is queued, running or waiting. Not-started cards (
idle) show here too. - done: the run is over. A card stays here only while unread on this device:
lib/neoSeen.ts, written when a card page shows a finished run, or by “Mark all read”.
The Filters column’s NEO entry counts what needs you.
Web push (apps/api/push/) is hand-rolled on WebCrypto. The RFC 8291 aes128gcm encryption is
checked byte for byte against the RFC’s published example. The RFC 8292 VAPID ES256 token was
checked live: FCM rejects a tampered one with “invalid JWT”. Push is on only when
VAPID_PUBLIC_KEY and VAPID_PRIVATE_KEY are set; otherwise /api/push/* answers 404 and
Settings → Notifications says it is not set up.
-
Subscriptions: a browser’s subscription is the opt-in, one
push_subscriptionsrow per browser. A push service answering 404/410 deletes the row. -
Endpoints: a subscription’s endpoint comes from the browser, so the server posts only to the known push services (FCM, Mozilla, Apple, Windows) over https, checked when it is saved and before every send, never following a redirect.
-
Triggers:
subscribeNeoPushreads the run hub and fires on three events:- a NEO run that ended on its question;
- a squad parked on
approval:; - a NEO run that finished or failed.
Each fires once per run and kind, never after a Stop, and goes to the run’s own user. Notifications are tagged by card, so a newer one replaces an older one.
A card page watches NEO’s run live. Every run save is published in-process
(PublishingWorkflowRunRepository → RunProgressHub). GET /api/tasks/:id/main/stream is SSE: a
run event (MainRunView: active flag, the steps NEO and each squad reached) on connect and after
every save of NEO’s latest run or one of its squads, plus a ping event every 5 s (Bun’s idle
timeout is 10 s). It is a named event, not an SSE comment, because the page reopens a stream silent
for 15 s: a proxy can hold a connection open to an API that restarted, and a page that thinks it is
streaming does not poll. A squad is recognised by its queued save, the only one carrying parentRunId.
GET /api/tasks/:id/main/run is the same view as a snapshot; the page polls it every 5 s while the
run is active and the stream is down. The hub is in-process: a second API replica would need Postgres
LISTEN/NOTIFY before the stream reaches a page served by the other one; polling covers it meanwhile.
The transcript. An agent node with a nodeEvents store writes each step of its loop as it ends
(AgentRequest.onStep, from the AI SDK’s onStepEnd): one workflow_node_events row per thing the
model wrote, and one per tool it called, holding the tool’s name, its input (capped at 500 characters)
and why it failed (thrown, or the error string it returned). A tool’s output is never stored. It
can carry what the run’s actor could read, and the transcript is shown to everyone who can see the
card. Rows are append-only; one execution writes at most 200, and a failed write is logged and never
fails the node. The serial id is the cursor: the stream sends each row as a step event with
id: set, so a reconnecting EventSource resumes through Last-Event-ID, and
GET /api/tasks/:id/main/events?after=<id> reads the rows of NEO’s latest run and its squads (once
on open, so a finished run still shows what it did, and polled with the snapshot when the stream is
down). A node re-executed after a crash may repeat its rows. The rows are deleted with their
workflow’s runs.
The page (MainRunProgress.tsx, AgentTranscript.tsx, useMainRun.ts) shows a spinner, the steps,
and under each step its transcript, and refreshes the timeline on every change. Once the run ends the
same tree folds into a closed “What NEO did on this card” section. On the board, GET /api/tasks/main/running lists the caller’s cards with a
NEO run in flight (polled every 10 s while a card on the board is NEO’s), and their bot icon takes the
accent. When NEO starts on a board-native card still in todo, handToMain moves it to
in_progress as its creator; a card with its own lifecycle (link, contact, post) is left alone.
NEO sees the card’s images; squads do not. A screenshot-only card used to reach NEO as a
URL, and NEO asked for a description of a picture it could not see. The NEO node now carries
imagesFromCard: <taskId>, and AgentNode loads that card’s images at run time through
ICardImageLoader (apps/api/tasks/adapters/CardImageLoader.ts). Bytes cannot ride in the
serialized graph, and they are not items either, because every item is checkpointed.
<img src>sources indescriptionandsources inbodyare read, capped at 4 images of up to 5 MB each.- An own-asset URL is read straight from storage. A
privkey is read only for the run’s org. - Any other URL goes through the SSRF-guarded
createRemoteAssetFetcher(https only, private hops refused, 10 s timeout). - The card itself is read as the run’s actor, because the param is reachable through raw params on the canvas.
- An image that is skipped is named in the prompt.
The images travel as AgentRequest.images and VercelAiReasoner maps them to AI-SDK file parts,
which each provider (Anthropic, Cline) converts to its own shape. A model the catalog lists without
the vision capability refuses the request with failureKind: 'unsupported' before any call is
made, so the gateway fails over. If every candidate refuses, the node reruns on the card’s words
alone. MAIN_AGENT_SYSTEM tells NEO to write what the images show into the squad objective, so
squads and the write-up stay text-only and cheap. The one exception is an image squad redrawing a
picture: it gets that one picture as its reference (see the squad-kinds table).
The summarise agent (reader tier, tool-less) writes the account from collect_squads’ summary —
it has a goal of its own; before this it had none and halted on every delegated run. It is told to
state a coding squad’s CI verdict (green / RED / unknown) and never to call a red or unknown PR done.
When the write-up itself fails soft (an empty text with the reason on error, e.g. “Insufficient
balance”), log_activity writes that reason plus collect_squads’ summary (its fallbackNode), so
the PR link survives. The coding kind waits 120 minutes, the rest 30 (squadTimeoutMs).
A squad’s result is its Work node’s SAMPLE, so Work carries a raised cap (2026-09-30).
describeSquadOutcome reads the Work node’s stored sample, and the default cap (8 KB) DROPS a single
item over it rather than truncating. A research answer or a translated article past 8 KB reached the
write-up as “produced no result”. Every squad Work node sets sampleMaxBytes: SQUAD_WORK_SAMPLE_MAX_BYTES (64 KB, the zen figure); a squad run is ad-hoc, so the raised cap never
reaches a saved workflow.
What a squad produced reaches the card as data (2026-10-10). An agent node reads its tool
calls’ OUTPUTS before dropping them (runResultFromToolCalls): a prUrl (repo_open_draft_pr,
repo_push_edits) is a pull request, a task_… id from create_post_draft / create_task is a
created card. They ride the node’s item as produced (RunResult, capped at 5 PRs and 20 cards).
describeSquadOutcome merges produced across every node that ran and lays the last CI node’s verdict
on its PR (the CI node’s own prUrl stands in when no tool reported one). collect_squads merges
them across squads, and log_activity’s graph-only resultNode param stamps them on NEO’s line as
meta.result, on a “Nothing to log” line too. The card draws them as blocks under that line: a PR
chip with its state and CI badge and a “changed files” list, and a chip per created card. The PR is
read live by GET /api/tasks/:id/main/pull-request?seq=&url= with the VIEWER’s own github://
channel (IRepoHost.readPullRequest: the PR, the compare API for its files, its head’s check runs,
no logs). That route reads only a PR the line seq names. Without a GitHub channel it answers the
reason, and the card shows the verdict stored when the run ended.
An empty squad reports what its steps said (2026-10-09). When the Work node emitted no items,
describeSquadOutcome carries every ran node’s message as notes, and collect_squads renders
“produced no result. Its steps reported: …”. A deterministic squad (the link one) names its reason
in node messages, not items — told only “produced no result”, NEO guessed a cookie wall three times
on a page that was a client-rendered shell.
A thin HTTP page is rendered in Chrome before it is called empty (2026-10-09).
BrowserFallbackScraperAdapter (the scraper behind read_url, enrich and the agent scrape tool)
renders any page whose plain-HTTP text is under 200 chars (or that HTTP could not read) through the
CDP sidecar, SSRF-guarded on its own, and keeps the rendered text only when it is longer. An Angular
or React app answers HTTP with <app-root></app-root>; no host list could name every one.
The content squad files a post draft, never publishes (2026-09-30). A card asking for a post
written, translated or rewritten used to go to research, whose worker holds only scrape, so the
draft it asked for was unreachable. NEO now picks content. Its worker files the draft with
create_post_draft — opt-in like create_workflow, refused at depth 0, one destination per
call, a thin binding over the MCP create_post handler (runCreatePost), so the row is the one
POST /api/tasks/content makes. It offers no status, schedule or mode: a person publishes from the
board. The draft card is the deliverable; the write-up relays its link.
A person’s skill steers the writer. A card tagged skill:<slug> (or skill:<id>, optionally
@<version>) shows NEO a Skill: line; NEO passes it to spawn_squad as skill; the content
squad’s Work node carries it as its skill param. The agent node resolves it through
resolveSkillSteering as the RUN’s user (kind enricher, the writing-voice slot remove_ai_slop
uses) and ADDS its instructions to the system prompt — unlike analyze, it does not replace the
goal. A skill that does not resolve leaves the node on its goal, and the node message says why.
create_post_draft records the ref as a skill:<ref> tag on the draft, so the card shows what its
words were written under.
Translating or recomposing a post is this squad plus a skill — not a node (2026-10-09). “Translate
task_… into French for LinkedIn” names its source as a CARD, which the squad cannot see, so the
worker also holds the in-process get_task: it reads a named card’s body with it (scrape of the
card’s sourceUrl would read the page the post was written FROM) and writes in the language the
objective asks for. NEO keeps a source card’s id in the objective word for word. A dedicated
translate / recompose_post node was rejected: a language is one instruction in a goal, and a node
per transformation would each wrap one prompt. The destination’s copywriting rules are a skill
(skill:<slug> on the card); the deslop pass is the “Remove AI slop from new drafts” template,
which sweeps every draft post hourly (en + fr rules). The agent node now declares skill
(format: 'skill'), so a drawn graph can steer its own writer the same way.
The coding squad works through GitHub, never a shell. A github://login@github.com connector
holds a fine-grained personal access token (checked with GET /user before it is stored). The repo
tools (apps/api/code/repoTools.ts, opt-in, offered only to a node that names them) resolve the
RUN user’s token per call: repo_list_files, repo_read_file (both take an optional branch),
repo_open_draft_pr, which writes one commit on a fresh viite/squad-<run> branch through the Git
Data API and opens a draft PR — the default branch is never written, and a retry finds the PR its
run already opened — and repo_push_edits, the fixers’ tool, which commits onto THE RUN’s squad
branch only (the model cannot name a branch; no open PR there ⇒ refused; the ref moves
fast-forward, never forced).
The worker sends edits ({find, replace}, each find matching exactly once, applied by
applyEdits to the file as it is now), not whole files: the first live coding squad found the right
change and opened no PR because retyping a 500-line file blew a small model’s output cap. The coding
planner is bounded by a 100k-token budget with 8 steps as a backstop, and the worker by 30 steps;
a capped planner writes its plan on its last step instead of handing over an empty one (see The
token budget above). An agent that a cap stopped carries the
reasoner’s error (“output truncated…”) on its item, so the write-up can say why no PR exists.
The token is the boundary: a squad reaches exactly the repositories the person scoped it to.
Reads are windowed (2026-09-28). An agent loop re-sends every tool result on every later step, so a read is paid for once per remaining step. Unbounded, the coding planner was 75–99 % of a run (~500k input tokens in ~10 steps on the planner tier). On top of the run and Plan token budgets above:
repo_read_filereturns a window of at most 300 lines / 20k characters, withtotalLinesand anexthint.startLine/endLineread another part. The text is unnumbered, sofindstrings copy cleanly, and edits still resolve against the whole file.repo_list_filespast 300 files answers with the sub-folders and their counts (plus the files directly in the folder), so the next call can name aprefix.- The coding Plan runs on the
workerrole: it explores rather than reasons, and the planner tier cost 2.1× per input token for no gain. The research squad keeps the planner tier.
The squad checks its own CI (2026-09-28). Nothing in the squad can build or test, so PR #633
went red on a JSX comment and the text-only reviewer passed it. Now Work → CI 1 (await_checks)
→ fail → Fix 1 → CI 2 → fail → Fix 2 → CI 3 → Review; every pass goes straight to
Review. The two fix attempts are written out as nodes, not looped, so the bound is visible in the
graph. await_checks reads the head commit’s check runs, each failed run’s annotations and the
error lines of its Actions job log (the signed log URL is fetched without our token), and routes:
only a real red spends a fix; green, no CI, still-running-at-30-minutes and a token that may not
read checks all go down pass with the reason in ciSummary. A pending CI parks re-entrant every
2 minutes, holding no slot. Reviewer and write-up both see the verdict. The token needs Checks:
read and Actions: read on top of Contents + Pull requests.
What a model may hand to NEO. NEO reads card text as a prompt and its coding squad can open a
PR with the owner’s token, so assigning NEO is a human act on the board: MCP update_task takes no
assignedAgent, and create_task hands only a contact to NEO — which reaches the fixed contact
squad, never the classifier. This replaces the old rule that no card could be relabelled into
executable work.
create_workflow — an agent authors a workflow (shipped 2026-09-25)
Section titled “create_workflow — an agent authors a workflow (shipped 2026-09-25)”NEO can save a workflow for the person whose card it holds — “three days after a signup on my
landing page, send me a summary” becomes trigger.webhook → wait {forMinutes: 4320} → agent → send_email (or an agent with create_task when a card is wanted instead). An
automation acts for someone on every fire, which is a different risk class from filing a card, so
the tool (apps/api/workflow/tools/createWorkflowTool.ts) is fenced by five rules:
- Saved inactive, through the ONE create path.
createWorkflowDefinition(workflow/usecases/) is whatPOST /api/workflowscalls too, so the tool can’t drift from the route. It never savesactive: true. The person reviews the graph on the canvas and arms it, and activation re-validates. - Held to a stricter bar than a human save (
authoredGraph.ts). A person may save work in progress; an agent hands over a finished proposal. The graph must resolve (no cycle or dangling edge), have a trigger, name only types THIS deployment registered (registry.has; activation does not check that), leave only by outputs its descriptors declare, fill every required input, and stay within 20 nodes. Problems go back to the model as sentences it can fix. - An authored
agentnode must list itstools. Without the list it would get every tool, publishing included, and the canvas does not show that list. It may not namecreate_workflow: a workflow must not author workflows. - The platform mints the webhook token (
wh_<36 hex>), overwriting whatever the model wrote, and returns the URL. A model-picked token is guessable and collides across users. - Opt-in, and only inside the
workflowsquad.create_workflowis not in the shared map a node withouttoolsreceives. It sits inAgentNode’soptInToolsand is offered only to a node that names it — the workflow squad’s worker. A top-level caller (depth 0, NEO included) is refused: NEO decides THAT a workflow is wanted and hands it to a workflow squad.
The result gives the canvas link and any webhook URL; the write-up quotes them onto the card’s
timeline. Known limit:
webhooks are authenticated by ONE deployment-wide WORKFLOW_WEBHOOK_SECRET. That works for a
landing page we sign server-side, and for no other org’s site until secrets are per workflow. A
crash between the save and the checkpoint re-runs the node and saves a second copy, which gets a
-2 slug and is also inactive.
The MCP toolset, in process
Section titled “The MCP toolset, in process”Our own agent nodes call the same twelve tools Claude.ai calls through /api/mcp. openMcpTools
connects a real @modelcontextprotocol/sdk client to buildMcpServer over InMemoryTransport — no
HTTP, no OAuth round trip. (ai@7.0.8 exports no MCP client.)
Through a client rather than by binding the exported run* handlers, because the latter is a second
list of names, descriptions and schemas to keep in step with mcpServer.ts — and the first to drift
would do so silently. Going through the client means the agent sees exactly what Claude.ai sees,
including a tool added tomorrow that nobody remembered to add here.
- Opened per node execution, as the run’s actor. The MCP server is tenant-bound, so one built at
boot would run every run’s tools as whoever booted first. There is no field a call can set to act
as someone else. Closed in a
finally, including on the park path. - Names are not prefixed, so a workflow naming
search_contactsuses the same word Claude.ai does.create_taskis in both sets and the LOCAL binding wins — silently swapping which implementation answers a name would be a behaviour change disguised as a connection. - Wrapped as
AgentToolBindings, soparams.toolsfiltering, the tool policy and the trace all keep working rather than each being taught about a parallel “remote tool” concept. buildMcpreturns ONEbuildServerFor, shared by the HTTP route and the agent, so the connector and the agent cannot be wired to different repositories.- Late-bound (the
codingDispatchshape): the workflow registry is built long beforebuildMcpinindex.ts, and a session that will not open leaves the agent with its local tools.
Ad-hoc runs carry their graph on the row (closed 2026-09-27)
Section titled “Ad-hoc runs carry their graph on the row (closed 2026-09-27)”An ad-hoc run’s graph used to ride its message only, so the row-based wakes (the boot sweep, the
due-time backstop, the cohort wake) had to skip ad-hoc runs. On prod that meant every NEO chain sat
out its whole park after its squad finished — a coding squad done at 11:44, NEO woken at 12:41 on
its 60-minute timer. withQueuedRun now stamps an ad-hoc run’s graph on workflow_runs.graph
(never blanked by later saves), and one wakeMessageFor(run) rebuilds the wake from the row, graph,
parent link and depth included — so a settling squad wakes NEO at once, and a restart resumes an
ad-hoc run like any other. Rows queued before the column existed carry no graph and still fall back
to their booked timer.
Triggers — the graph is the binding
Section titled “Triggers — the graph is the binding”Four kinds, one dumb TriggerNode (trigger.manual / trigger.schedule / trigger.event /
trigger.webhook): a trigger just forwards its seed items; the difference is entirely in how each
FIRES.
resolveWorkflowTriggers(graph) reads the binding off the graph — no subscription table to
drift from the canvas, which is the failure job_schedules.templateError had to exist for. A cron
lives on the node it starts.
Event triggers ride the in-process event bus: WorkflowTriggerDispatcher guards with
isDomainEventType (the bus also carries per-request activity events), looks up listeners via a
GIN-indexed jsonb containment query that is only ever a pre-filter (survivors are re-checked with
the same resolveWorkflowTriggers the canvas uses), and hands each run to a durable enqueuer —
the bus is not durable across replicas. The event vocabulary (task.created,
task.status_changed, link.captured, …) and each event’s declared payload fields are exposed at
GET /api/workflows/event-types, feeding the condition builder’s dropdown.
trigger.webhook — the one workflow surface with no session
Section titled “trigger.webhook — the one workflow surface with no session”Mounted at /api/workflows/webhooks/:token, BEFORE the authed app, because an inbound webhook
arrives from another system with no cookie. That makes it the only place a stranger can cause the
platform to spend credits, so four rules are load-bearing rather than stylistic:
- The signature authenticates; the URL only addresses. The token is in the graph, readable by
anyone who can open the canvas. The secret (
WORKFLOW_WEBHOOK_SECRET) is in the environment and never in a graph. - It fails closed. No secret configured ⇒ every request is refused with 503. “Not configured” means off, never open.
- HMAC over the RAW body, compared in constant time. Parsing and re-serializing would verify a different byte string than the one that was signed.
- Identity is never read from the body. Whose workflow this is comes out of the token lookup; a
userIdin the payload is attacker-controlled.
An unknown token answers identically to a bad signature, so the endpoint is not an oracle for which tokens exist. A failed enqueue still answers 202 — a 500 invites a retry, and a retry after a partial failure produces duplicate runs.
Saving a broken graph is legal — work in progress must be storable. Only activation validates:
resolveWorkflowGraph (cycles, dangling endpoints, duplicate ids/names) must pass and at least one
trigger must exist. Create is never active, whatever the body asks — drawing a workflow is not
consent to run it.
Conditions and expressions
Section titled “Conditions and expressions”interface Condition { left: string; operator: Operator; right?: unknown }interface ConditionGroup { combinator: 'and' | 'or'; conditions: Condition[] }Thirteen operators (is, contains, gt, matches, …), case/accent-folded string matching
lifted from invoiceGate, numeric coercion for form-borne strings, and — the part users trust —
a signals audit trail recording which predicate fired per item, persisted per run and
rendered by the run view as “why did this item go down the false branch”.
{{ $json.field }} addresses the current item; {{ $node['Name'].json.field }} a named upstream
node’s output. Resolved per item at run time — deliberately a different syntax from the
${input:}/${env:}/${setting:}/${skill:} family, which resolves once at job creation in
the old engine. Same-looking, different time, different scope.
The node vocabulary (28 types)
Section titled “The node vocabulary (28 types)”| Node | Category · mode | Outputs | Notes / gating |
|---|---|---|---|
trigger.manual / .schedule / .event | trigger · det | main | always registered; .schedule takes cron, .event takes eventType |
if | decide · det | true / false | one condition group per node; the only node holding conditions |
read_gmail / read_drive | ingest · det | main | share performRead; watermark scoped to the workflow id; items carry externalId = provider message id |
read_url | ingest · det | main | second-hop page fetch; canonical-URL dedupe, boilerplate/thin-page drops, all counted |
read_notion | ingest · det | main | one shape now — the step used to write items AND rows for the same records |
agent | enrich · ai | main | tool-loop (scrape + create_task); needs channels. A reasoner error is fail-soft by default: it rides the answer as error and the node still runs. haltOnError (set on every squad planner and worker by SquadTemplate, never on the reviewer) fails the node instead when the model call errored and nothing was produced, so a dead key ends a squad failed with the model’s own message rather than completed with empty steps; an error after text or a draft/PR exists is kept |
enrich | enrich · ai | main / declined | rows as items; a FAILURE leaves on neither output (retry by construction) |
analyze | enrich · ai | main / dropped | partitions instead of dropping — a dropped item can finally be tagged, ending the re-read-forever bug |
triage | decide · ai | actionable / dismissed | cards created in-node (idempotent manual:<userId>:<messageId>), every terminal verdict written to the import ledger; verdict rides the items’ json (taskId, triageTitle, …); fail-soft placeholder + 5-consecutive-failure halt shared with the old step; priority (auto default, else low/medium/high) pins the card’s priority over the verdict’s — a branch that already knows what it cards (papers to sign) sets it |
attachments_to_drive | act · det | main | after triage.actionable: downloads each attachments ref from the owner’s mailbox, uploads it to the owner’s Drive into a folder the app created (folder), attaches the file to the card as an owner document pointer; idempotent on retry (upload stamped appProperties.viiteSource = hash of message id + filename — Gmail re-issues attachment ids on relabel — and the pointer dedupes by URL); a failure leaves on no output; a connection without drive.file halts with a Reconnect fix. Registered only with attachmentClientsFactory + taskDocuments |
collect_invoices | decide · hybrid | invoices / rejected | free gate first, LLM only for the ambiguous band; gate-only without channels |
best_of_tech | ingest · hybrid | main / picks | files the digest card; picks is dispatch-shaped; the brief param opts into page-grounded briefs (cost, never shape); picks: "showhn:3, github:2" sets a per-board quota (each board its own shortlist, a board the model shorts filled on score and reported unjudged, ≤ 10 total, output in quota order); picks never repeat a story: before shortlisting, every scored entry whose storyKey(url) matches the sourceUrl of one of the owner’s 500 most recently touched post rows (any status, archived included) is dropped, and picks within one run are distinct by storyKey across boards; main reports alreadyPosted |
dispatch | act · det | main | routes by destination URI scheme to the shared target map; passes input through so tag can follow, each item carrying createdTaskIds — the board cards its rows became, reported by the board-writing targets (post, dashboard, link) as DispatchResult.cards, created ones only — so add_parent can follow; autoPublish (post destinations only) resolves publishTimes in tz to upcoming slots (upcomingPublishSlots: 12am = midnight, a passed time = tomorrow) and hands them over as publishSlots (publishInDays moves every slot N days out — J+2 — and skipWeekends moves a Saturday/Sunday slot to Monday, both read in tz) — the drafter arms each NEW post reviewed + dueDate once the destination’s full rules pass; a skipped source or a rule-breaking post takes no slot, posts past the slots stay drafts |
tag | act · det | main | labels the messages that reached it (ids off the edge, not a bag) — wiring both verdict outputs of a filter into one tag is the old flag-once behaviour, drawn |
maps_place | act · det | main | a Maps link’s place (parseMapsPlace: ?q=Name, address + ftid) filed as a company/contact card by mapsPlaceIdempotencyKey, not handed to NEO; main is the link card’s write-back (title = place, summary = address); a link naming no place leaves nothing, so update_link marks it failed |
update_link | act · det | main | writes the first input row back onto the link card; flag runOnFailure so an upstream abort — or a completed run that never reached it — marks it failed with the real reason |
find_linkedin_profile | ingest · det | main / declined | the contact acquisition run’s first hop: a web search restricted to linkedin.com/in (keyless DuckDuckGo behind the IWebSearch port), the top profile read best-effort through the scraper; declined = not found or search unavailable, still carrying the card so the next hop can work from its notes |
enrich_contact | enrich · ai | main / declined | one JSON answer per person — name parts, company, role, location, profile (only a candidate URL), a summary and the category tags to file them under; a thrown call leaves on neither, all-thrown halts |
update_contact | act · det | main | writes findings onto the contact card filling empty fields only (typed beats found) — and only those the evidence ledger VERIFIES (see “The evidence ledger”); the rest become suggestions — merges tags, sets enriched/failed; flag runOnFailure like update_link (runs on failure AND on a completed run that skipped it) |
read_contact_mail | enrich · det | main | the person’s own emails as first-party evidence under an EXACT key (the card’s typed email): searches the run owner’s mailbox (gmail:// is personal-scoped — never a colleague’s) for from:/to:/cc: the address, ≤ 10 messages, and reads the From: display name and the signature block (mailSignature.ts, deterministic, no model) of the messages the person SENT. No body, subject or snippet is carried or stored — the item gets mail: {name?, role?, company?, phone?, linkedinUrl?, xHandle?, messages, fromThem, lastSeen?} and a mailLookup (found / no_messages / no_email / not_connected / unavailable). Always registered, never halts: a dead token or no connector passes the item through. The result is ledger evidence (mail.signature, mail.header), not a write |
enrich_provider | enrich · det | main / declined | the licensed-data hop of the contact run (and, with subject: company, of the company run — keyed on the domain, then the name; every output item carries providerSubject): matches the card against the org’s enrich://…@peopledatalabs.com connector (own API key, IEnrichmentProvider port, one adapter per host) by typed LinkedIn URL, then email, then name + company/location — never by a profile URL the web search merely guessed. main = matched (providerRecord rides on the item, its profile URL becomes a candidate for enrich_contact); declined = not_found / insufficient / unavailable (+ providerError) / not_connected, all still flowing to enrich_contact. Always registered (the key is per-org, read per run), never halts, bills the org’s own provider account — credits are reported, not debited from viite credits |
read_company_site | ingest · det | main / declined | the company run’s first hop: fetches https://<domain> (the card’s domain, else its website) through guardedFetch — every hop must resolve public — and keeps the title and up to 6 000 characters of text; declined = no domain (no_domain), the site refused, 404’d or answered a non-page (unavailable, + siteError) or had no readable text (empty), all still carrying the card so the next hop can work from its name and notes |
enrich_company | enrich · ai | main / declined | one JSON answer per company — industry, size, location, LinkedIn company page (kept only if it appears in the sources shown to the model), a summary and category tags — from the site text, the notes, the typed fields and the provider record (which outranks the site’s own copy but never a typed field); same halt-on-all-thrown rule as enrich_contact |
update_company | act · det | main | writes findings onto the company card filling empty fields only (typed beats found — on a company that includes summary, which the editor offers as a field) and only those the evidence ledger VERIFIES, merges tags, sets enriched/failed, logs the run on the timeline; runOnFailure like update_contact |
extract_contacts | act · det | contacts / messages | the mailbox’s people as contact cards and its organisations as company cards: splits from (or to, or both — headers) into mailboxes, skips the run owner’s account address and exclude addresses/domains, then (skipAutomated, on by default) asks classifySender which card each address deserves. An organisation — a role or no-reply@ mailbox, a brand display name, or mail that DECLARES itself bulk/automated (Auto-Submitted other than no/auto-replied, List-Unsubscribe-Post, Precedence: bulk/junk, Gmail CATEGORY_PROMOTIONS; read_gmail carries these as automationHeaders/labelIds, read for the SENDER only) — files or reuses the company card keyed on its registrable domain (updates.resend.com → resend.com), never a contact; List-Id, bare List-Unsubscribe, Feedback-ID and the other Gmail categories are ignored because a person’s mail to a group or through SES carries them too. No card for mail infrastructure (mailer-daemon@), newsletter platforms (substack.com) or an organisation with no domain of its own (relay, free mail). A new contact whose domain already has a company card is linked to it. The run message says why each address was skipped. Off: every address is a contact. Dedupe is contactCardIdempotencyKey / companyIdempotencyKey (org-scoped), so no import ledger — a replay is one lookup per address and spends nothing; an existing card is never edited, a new one is NOT handed to NEO. contacts = one item per distinct person (taskId, name, contact, the sender’s body as notes) shaped for find_linkedin_profile/enrich_contact; messages = every message got through with contactIds and companyIds, for tag; a thrown write leaves on neither (retried) |
read_tasks | ingest · det | main | the AI-slop cleanup run’s first hop: searches the board, or — when an upstream task.* event named cards by id — hydrates exactly those instead; drops cards already carrying skipTag (writing:deslopped) before they reach the model |
remove_ai_slop | enrich · ai | main / clean / declined | runs the deterministic detectSlop scan first — zero warnings leaves on clean with no model call and no channel required; a scored field is rewritten and judged by judgeRewrite (facts survived, length sane, warnings did not rise); a judged-bad answer leaves on declined with the reason, a thrown call leaves on neither (retried next run) |
update_task | act · det | main | writes the rewritten field back onto the card and tags it (writing:deslopped / writing:deslop-declined) so the same card is judged once; the body cap is enforced with the same constraintsFor(socialPlatformForHost(...)) rule a hand edit is held to |
add_parent | act · det | main | the board’s “Group under” as a step: files each item’s cards (createdTaskIds from a dispatch, else taskId) part_of ONE parent. parent (format card, the card editor’s ParentPicker on the canvas) is a picked card id — gone or unable to hold cards ⇒ halt — or a typed title: the owner’s live card of that exact title among GROUP_ENTITIES, else a task created on the first run under manual:<userId>:parent:<title> (nothing is created at edit time). Each link passes partOfRefusal — a card under ANOTHER parent is left there; items pass through; gated on the link store |
delegate | decide · det | main | starts ONE squad of a named kind (kind + objective/repo or taskId/url) through the same prepareSquad as spawn_squad — same depth cap, same per-kind timeout — and parks on it; emits the spawnedSquads item collect_squads reads; an enqueue that throws halts rather than parking on a child that never started |
collect_squads | ingest · det | main | reads the finished squad runs named on its input and emits one item with a summary; no ids ⇒ no output. A failed squad is READ, not failed with, by default (NEO’s write-up reports it); haltOnFailure makes the node halt with the squads’ own reasons when any squad failed, its run is missing, or it had not finished when the parent woke on its timeout — set by idea-to-drafts, whose only job is the squad’s work |
await_checks | decide · det | pass / fail | the coding squad’s CI gate: finds the RUN’s squad-branch PR, reads its head commit’s checks (annotations + job-log error lines on a red), parks re-entrant every 2 min while pending (bounded 30 min from the commit; 3 min for a commit with no checks yet); fail only on a real red, everything else pass with ci + ciSummary; never halts on GitHub |
log_activity | act · det | main | appends the first input’s text (or the field named by bodyKey) to a CRM card’s timeline (task_activities) as an agent-authored entry of kind (default system); the domain validator decides — an empty human-kind body or an over-long one halts the node rather than landing a broken line; flag runOnFailure so a halted upstream still leaves a line naming the reason; an item with no text but an error (an agent that failed soft) logs that error, plus the summary of the node named by fallbackNode |
list_overdue_invoices | ingest · det | main | the org’s invoices with status = open and dueDate < now — the money tiles’ overdue bucket, through the SAME InvoiceFilter.overdueBefore — one item each, oldest due first: invoiceId, number, daysOverdue, step (the reminder step reached: 0–3 at D+3 / D+10 / D+20), amount, the customer snapshot (customerName, customerEmail) and the Stripe hostedInvoiceUrl. Org-scoped by the RUN’s org, never a param (no org ⇒ the shared halt with its fix). minDaysOverdue is pushed into the query, so limit counts invoices that qualify; a cut limit marks the node incomplete. Registered when the deployment wires the invoice ledger (read-only: only search is handed over) |
draft_invoice_reminder | enrich · ai | main / declined | one tool-less model call PER invoice (the stock agent runs one goal per execution and emits one item, so it cannot write five drafts) in the person’s voice: the skill input ADDS their voice to the reminder brief (it does not replace it), tone from toneForStep — friendly at step 1, firm at 2, final at 3, never a threat. The model writes a subject and a body and nothing else: any URL it writes is stripped (the payment link is added at send time from the ledger), and it never sees an address. declined = no step reached or no customer email; a thrown call leaves the item on neither output (tomorrow’s run retries); every call failing halts. Registered with the AI gateway |
file_invoice_reminder | act · det | main | files each draft as a plain task card tagged invoice-reminder, subStatus: awaiting_approval, the draft as its description (the field the ordinary editor shows on a task). ONE card per (invoice, step): idempotency key invoice-reminder:<orgId>:<invoiceId>:<step> on the unique partial index, so a replay or tomorrow’s run files nothing twice and losing the unique-key race is “already filed”; a firmer step first closes the same invoice’s softer UNSENT card (superseded), so an invoice has one open reminder at a time. It cannot send — its only dependency is the task repository. Registered only with the transactional mail path (RESEND_API_KEY): a reminder card exists to be approved and emailed |
send_email | act · det | main | emails the first input’s text (or the field named by bodyKey, after prefix) to the RUN’s actor — the address is looked up from context.userId at send time, never read from params, and the descriptor has no recipient input: a model-authored graph must not be a spam relay. subject defaults to “A message from your workflow”; subject/body capped at 200 / 50 000 chars. Sends through the transactional path (RESEND_API_KEY + SMTP_FROM) with context.idempotencyKey as Resend’s idempotency key, so a crash-and-replay mails once; registered only when RESEND_API_KEY is set. An empty answer sends “Nothing to send — error or runError, so it works as a runOnFailure closer); nothing at all ⇒ clean halt, no mail; an owner with no email fails the run |
watch and the classifieds source are deleted, not ported — the generic If node over a
time-series read replaces the bespoke comparison (apps/api/observation/ stays;
social_audience writes through it).
implement_task is deleted too — it was the old “Run agent” button’s node: an agent that read a
read-only copy of the source tree baked into the API image (read_file / list_files,
AGENT_WORKSPACE_ROOT) and appended a dry-run propose_patch diff to the card. Coding work now goes
to NEO, which hands it to a coding squad that opens a real draft PR through the owner’s
github:// connector (see “NEO and squad kinds”). Migration
20260926120000_deactivate_implement_task_workflows switches off any saved workflow still holding
the node, so its schedule cannot fail on an unregistered type.
The import ledger — what stops a message being imported twice
Section titled “The import ledger — what stops a message being imported twice”workflow_imports (IWorkflowImportLedger), keyed (org_id, workflow_id, source, external_id)
with the uniqueness enforced by the index rather than a read-then-write check, so two concurrent
ticks cannot both pass.
It exists because the two older guards each have a blind spot. A source-side exclusion
(-label:<processed>) lives in a field the user owns: editing it discards every “already handled”
mark at once, and since a label-driven Gmail read deliberately drops the watermark’s after: bound
(runRead’s labelIsGuard), the next run re-reads a full unbounded page — capped at 100 — of
long-settled mail. tasks.idempotency_key only holds while a card does: a dismissal writes no
card to collide with, and a pruned, deleted or converted card releases its key. Together those two
turned one label edit into ~100 duplicate cards.
Three rules make it work:
- Every TERMINAL verdict is recorded —
actionableanddismissed. The dismissal is the point: it produces no card, so without a row it was invisible to every other guard and got re-judged by a non-deterministic decider on each re-read. - An UNJUDGED item is never recorded. A degraded decider or a thrown call leaves the item on
neither output, unlabelled at the source, and retried next run — recording it would make that
retry impossible. Same reason
collect_invoicesdoes not record an ambiguous message it had no tiebreak for: freezing a non-decision would mean connecting a channel later never revisits it. - A recognised item is REPLAYED, not re-decided — onto the output it earned the first time, so a
downstream
dispatchnever acts on a verdict nobody made, and it still reachestag, which is how the source exclusion re-converges on the label the user just changed to.
It back-fills itself rather than by migration: an existing card found by the idempotency check is written to the ledger as it is skipped, so the corpus is healed by the runs that touch it. Cards predate the ledger and nothing records which workflow imported them, so a migration could only have guessed.
workflow_id is in the key deliberately. Two workflows may legitimately read one mailbox — the
triage flow and the invoice collector both do — and an org-wide key would let whichever ran first
starve the other. This does not replace tasks.idempotency_key, which stays user-scoped and is
stricter in its own direction (one card per user per message, across every workflow); re-scoping
that key to org+workflow would permit cross-workflow duplicates that are impossible today.
The exclusion and the tag that satisfies it are a pair. read_gmail’s excludeLabel and the
downstream tag’s label are independent free-text fields, and setting them differently leaves a
flow that never converges: every run re-fetches the same page, hands it to a node that has already
handled it, and applies a label the query does not exclude. The ledger stops that from creating
duplicates but cannot make the query converge. findGmailLabelMismatches (domain) reports the
mismatch — on the canvas as you edit, and as a drift-lock over the shipped templates. It is reported
and never auto-corrected: rewriting one node because another changed is an edit the user did not
make, on a field only they know the right value for.
Calendar reconciliation — how a deleted event reaches the board
Section titled “Calendar reconciliation — how a deleted event reaches the board”calendar_to_tasks is the one ingest node that retires what it created. Everything else in the
tree is create-only plus an idempotency skip; this one keeps a card in step with a source that can
change under it.
A windowed read cannot observe a deletion. events.list over [now, now+N) simply stops
returning a deleted event, which is indistinguishable from one that moved out of the window, and
updatedMin never returns tombstones at all. Google’s answer is syncToken: the first call mints
one, every later call returns only what changed since — deletions included, as items with
status: 'cancelled'. That is the primary read, and edits arrive on the same delta.
Push notifications (events.watch) were considered and deferred. The callback carries no
payload — it says “something changed” and you must run the incremental sync anyway — and it
additionally needs a Search-Console-verified receiving domain plus channel renewal bookkeeping. It
is a latency optimisation on top of the sync, never a substitute for it.
Three cursors, because one read cannot do everything:
ingestion_sync_cursorsholds the per-calendar sync token. A separate table fromingestion_watermarks, not a column on it: that table’s contract is monotonicity (GREATESTon conflict), a token is an opaque string with no ordering, and — unlike a watermark — a token must be clearable, since Google answers410 GONEonce it ages out.- The
:farEdgewatermark survives, because a sync token has one blind spot: an event that never changed but scrolled into the window is in no delta, and the initial sync saw it while it was still outside. A warm run therefore also reads[lastFarEdge, now+N)unfiltered. - The window itself is applied in the step. A sync token may not be paired with
timeMin/timeMax, so the delta is unbounded in time; creates are filtered client-side. Edits and deletions are not, because they only ever touch a card that already exists.
Four rules govern the writes:
- Only a
todocard is ever touched. Once the user moves a card, it is theirs — Google changes are counted (skippedUserOwned) and ignored. One rule covering edits and deletions alike, and the reason the step needs card ROWS (findByIdempotencyKeys), not merely the set of keys that exist. - A tombstone is honoured only from the calendar the card records. A meeting on two calendars is
deduped to ONE card — same Google event id, and the partial-unique index on
idempotency_keypermits only one — so the calendar that lost that race must not retire it.metadata.calendarcarries the owner. Cards created before that existed carry none; their tombstones are trusted, and a later run re-creating the card under the surviving calendar heals the rare wrong call. - An unchanged event writes nothing. Not an optimisation: a needless write bumps
updated_aton every calendar card every run, and that is the clock the board’s retention prune reads. - Absence is evidence only after a full sync. A deletion that happened while a token was stale produces no tombstone — the resync just omits the event — so a full sync also sweeps the window for cards whose events are gone. The sweep is scoped to the calendars that actually full-synced this run, since an incremental delta legitimately omits every unchanged event; sweeping on one would archive the board. A card naming no calendar is swept only when every calendar full-synced.
Cursors and tokens advance only after the reads and the writes succeed, so a failed write leaves the delta re-readable rather than persisting a token that would skip it.
onDeleted (step config) chooses archive — the default, which also tags the card
calendar:cancelled — or delete.
Not every calendar row is a meeting, and the title cannot tell you which. Google mixes working
locations, out-of-office blocks, focus time, birthdays and Gmail-derived entries into the same
events.list result. A working-location row in particular has an id, an all-day start and a
summary that is the location itself — “Home”, “Office”, a city name — so it lands on the board
looking exactly like an event, and filtering it by title means matching user text that is localised
and freely collides with a real meeting. The deterministic discriminator is the API’s own
eventType field (default · outOfOffice · focusTime · workingLocation · fromGmail ·
birthday), which CalendarEvent now carries verbatim: platform-ingestion reports the kind and
filters nothing, because which kinds are worth a card is the consumer’s policy.
ignoreEventTypes (step config, a multi-enum — checkboxes on the node form) is that policy.
default is not offered, since ignoring ordinary events would silently switch the node off. An
absent value takes the default ['workingLocation'] — those are exactly the nodes saved before
the field existed, and the rule was added for them — while an explicitly emptied list means
“ignore nothing”; collapsing the two would make the field impossible to switch off. A preset’s
${input:…} substitution arrives as a comma/newline-separated string and is accepted as one.
A card made from such an entry before it was ignored is retired on the next run and tagged
calendar:ignored, counted as retiredIgnored. That is deliberate rather than left to the absence
sweep above: the sweep would eventually notice (these events no longer enter liveEventIds), but it
fires only on a full sync — weeks late and unpredictably — and would tag the card
calendar:cancelled, which is untrue; nothing was cancelled. The todo-only rule still applies, so
a card the user has already moved is counted as skippedUserOwned and left alone.
The provenance backfill has two halves, because SQL cannot supply the second one. Cards created
before any of this recorded their event id in exactly one place: the idempotency key
manual:<user_id>:<event_id>. Migration 20260901130000_backfill_calendar_card_provenance parses
that back out into external_id and metadata.calendar.eventId. It cannot recover the CALENDAR id —
that was never persisted, and a card’s calendar:<name> tag is a human label, not Google’s opaque
id. So the step adopts the calendar id from the first live event it sees for that card, which the
first run after deploy does wholesale: no sync tokens exist yet, so every calendar full-syncs and
every card whose event is still live is adopted in one pass. Cards whose event is already gone are
swept on that same run instead. Until a card is adopted it has no calendar to compare against, and a
tombstone naming it is trusted from anywhere — the old behaviour, now confined to rows not yet seen.
Two details in that migration are load-bearing, both verified on a throwaway database. It matches the
key prefix with starts_with, not LIKE: a better-auth id is a mixed-case nanoid that may
contain _, which LIKE reads as a single-character wildcard — on real seeded rows the LIKE form
matched a different user’s key and would have stamped their event id onto the card. And it skips
rows whose metadata is not a JSON object, because jsonb_set does not ignore a scalar, it raises
cannot set path in scalar and would abort the whole migration over one malformed row. It leaves
updated_at alone throughout — a backfill is not the user touching their card.
Runs — what a run leaves behind
Section titled “Runs — what a run leaves behind”workflow_runs + workflow_node_runs (Drizzle, apps/api/workflow/adapters/). Per node: status,
skipReason, itemsIn/itemsOut, per-output counts (how a condition split), message, error,
incomplete, usage, condition signalSamples, and a hard-capped output sample — at most 3
items AND 8 KB serialized, whichever bites first (capSample), truncation labelled. The cap is not
a nicety: persisting whole contexts is what once put 631 KB of scraped bytes into one jsonb row and
failed writes on NUL characters. The sample doubles as the field picker’s second source: with
one item shape there is no declared schema for everything a node emits, so the designer offers
declared emits ∪ last-run keys, in a combobox that still accepts a typed path — both sources only
ever describe.
A run exists from the moment it is asked for, not from the moment it ends. It moves
queued → running → completed/failed/cancelled, and every one of those is a write to the same row
(the header upsert is what makes that one row). This is not bookkeeping: the queue is drained by a
single serialized worker, so a manual fire can wait minutes behind the cron backlog, and before this
nothing was written until the run FINISHED — for the whole wait the run view had nothing to show and
?run=latest resolved to the PREVIOUS run. An old timestamp sitting still reads as a dead button,
and it got clicked again: one workflow collected five stacked manual runs in four minutes.
Node rows follow the same rule. A node is written running before its executor is called and
overwritten with its outcome after, so the record always names the step the run is inside — the
answer to “where has it got to”, which for an LLM or a scrape is the only thing moving. Progress
writes are swallowed on failure: a courtesy write must never abort a run that is doing chargeable
work.
Two fields exist for the person reading a failure. trigger (manual/schedule/event) says
whether the cron did this or you did — a column that existed since the table did and went unwritten
until it became load-bearing for queue position. And a failed node may carry a fix
({ label, href }), declared by the node that knows the cause, rendered as a link under the
message; see “Errors that name a remedy” below.
The run list is deliberately lean (headers only); opening a run is its own request
(GET /:id/runs/:runId), because node rows carry the samples. A queued run additionally carries
queuePosition — how many runs will be picked up first — computed only for that state and never
stored, because it is true only at the instant it is read.
Templates — the ex-presets as graphs
Section titled “Templates — the ex-presets as graphs”WORKFLOW_TEMPLATES (packages/platform-domain/src/workflows/entities/WorkflowTemplates.ts): the
ex-presets whose steps are all native — inbox-links-to-board, newsletters-to-social-drafts,
gmail-triage, collect-invoices, daily-tech-digest, research-agent — plus graphs with no preset
ancestor: gmail-contacts (read_gmail → extract_contacts → tag), the AI-team pair, clean-ai-slop-drafts, self-sent-links (below), tech-autopilot (3 Show HN + 2 GitHub stories
scheduled to X at five times a day, unreviewed) and publish-scheduled-posts (*/15 →
publish → post_comment, the Publish-now graph on a clock). The second is what makes the first
publish: nothing else sweeps a post whose dueDate is in the future. idea-to-drafts (trigger.manual → delegate(kind: content) → collect_squads) is the NEO route drawn by hand: one idea, one content squad, ONE
draft post on the board in the voice of the skill picked in the inspector. It only files a draft that
waits for a person to move it to reviewed, so nothing publishes. objective ships blank, so the first
run says what is missing; a second network is a second delegate node.
chase-unpaid-invoices (trigger.schedule 0 8 * * * → list_overdue_invoices(minDaysOverdue: 3) →
draft_invoice_reminder → file_invoice_reminder) is “relancer mes factures impayées”: every morning, one drafted
reminder card per overdue invoice at the step it has reached (D+3 / D+10 / D+20). Nothing in the graph can
send, and nothing automatic does. The only sender is POST /api/invoicing/reminders/:taskId/send
(sendInvoiceReminder), a signed-in organisation admin’s click on the card’s “Approve and send” — session-only
(never an API key or MCP), no request body (text = the card’s description as last saved, recipient = the
invoice’s own billing contact re-read from the ledger, Reply-To = the approver). One conditional
awaiting_approval → sending claim makes a double click send once; the invoice must still be open
(re-read at the click); a failed send gives the card back; then outbound_messages (kind invoice_reminder,
deduped per org, invoice, step and recipient), an email line on the customer’s contact card and the card’s
own timeline, and the card moves to sent with the next step and when it falls due. invoice.paid /
voided / marked_uncollectible and the void route close the invoice’s unsent cards (closeInvoiceReminders,
the same claim, fail-soft). Stripe’s own dunning stays off (auto_advance: false): our status machine owns
the ledger. The scope of an approval is the card’s creator (a plain task is creator-private) who is also an
org admin; a colleague does not see the card. The template bakes no skill (the person picks theirs in the
inspector) and is offered only where the ledger, the AI gateway and the mail path are all wired.
A catalog in
code, not seeded rows — rows need an owner, a migration and an update story; a catalog versions
with the nodes it names. “Seeded” happens at clone time: GET /api/workflows/templates serves the
catalog filtered to what the deployment registered (same gating as the palette), and “Use template”
on /workflows is the ordinary create route handed the template’s graph. Two translation rules:
${input:x} placeholders became baked params (personal values left blank for the inspector), and
the side channels became drawn edges (declined/dismissed/rejected → tag). Drift is locked
from both layers: a domain test resolves every graph and checks each edge against real outputs; an
API test builds the full registry and asserts every template type registers.
gmail-triage forks before it triages. Two analyze passes run ahead of the tech filter, each fed
what the previous one dropped. NEWSLETTER_INSTRUCTION keeps an editorial issue sent to a list → enrich
(the issue’s own body, no read_url) → dispatch X draft → dispatch LinkedIn draft → tag: drafts
only, nothing publishes, and the LinkedIn dispatch is CHAINED after X so the mail is labelled once both
exist. LEGAL_INSTRUCTION keeps paperwork to sign → triage (LEGAL_TRIAGE_INSTRUCTION: the summary is a
- [ ] checklist of the TODOs; priority: 'high' pinned) → attachments_to_drive → tag. What neither
keeps reaches select and the tech triage, unchanged. Uploading needs the drive.file scope on the
gmail:// consent; a connection made before it was added reads fine and asks for a reconnect on its
first upload. A domain test locks the fork order and that every verdict of every fork reaches tag.
self-sent-links and gmail-triage split the inbox. A link shared to yourself from a phone arrives
as a subject and a bare URL. analyze sees nothing “about technology” in that and drops it, and an
x.com page would not survive read_url anyway. So mail you send yourself is captured by rule, with
no LLM: read_gmail (query from:(me…) to:(me…), SELF_SENT_QUERY) → dispatch(link://, tags [from:me, curation:aitools:scrape]) → tag(curated). It makes one link card per bodyLinks URL
that capturableLinks (urlFilters.ts) keeps. That is read_url’s page judgement plus no click-tracking
redirects (isTrackingRedirectUrl), PDFs kept, trailing punctuation cut, and at most 8 per mail. In
the link lane a row needs only url, and a lone link is titled from the subject. A mail with no link
worth keeping writes nothing but still passes through, so tag marks it processed rather than it
being re-read every run. A from: tag set on a dispatch node replaces the automatic provenance (see
dispatchTags).
Every node that writes tags on a card shows them as a Tags input (NODE_TAGS_INPUT: dispatch,
extract_contacts, triage, enrich, maps_place, update_link/contact/company), defaulting to
from:<workflow-name> — resolveNodeTags substitutes the saved workflow’s slug, which the engine hands
every node as NodeRunContext.workflowName, cut at a word boundary to fit the 40-character tag. An
ad-hoc run has no name: dispatch falls back to the node name, extract_contacts to gmail, triage to
triage, maps_place to maps, and the rest add nothing. On an UPDATE, a card that already names its
origin keeps it (withNodeTags). collect_invoices keeps from:invoices fixed — accounting finds
invoices by it — and its Tags input only adds. gmail-triage’s query carries NOT_FROM_ME_QUERY, so neither workflow reads the
other’s mail, and a domain test locks the pairing. Capture needing no AI is also why it is a separate
workflow: an LLM outage that fails triage (a 402 halts the run) cannot stop your links being captured.
To also draft a post for every link that can be read, add a branch off read on your own copy:
read_url (main) → enrich (main) → dispatch(post://<you>@x.com, linkInComment: true). An
unreadable page leaves read_url with no page and gets only its link card. The branch is not in the
template because its destination is personal. A dispatch with an empty destination halts, and a
failed halt stops the whole run, tag included, so every fresh clone would go red.
HTTP surface and SDK
Section titled “HTTP surface and SDK”Mounted at /api/workflows (createWorkflowsApp); static paths registered before /:id:
| Method | Path | |
|---|---|---|
| GET | /nodes | registered descriptors — the palette |
| GET | /event-types | event catalog + declared payload fields |
| GET | /templates | clone-able starting points, registry-gated |
POST / GET / GET /:id / PATCH /:id / DELETE /:id | CRUD; PATCH validates graph + trigger presence only when active: true | |
| GET | /:id/runs | run headers, newest first (limit 25) |
| GET | /:id/runs/latest | most recent completed run, nodes included — the field picker’s sample source |
| GET | /:id/runs/:runId | one run with node rows — what the run view opens |
SDK: WorkflowClient (platformClient.workflows.*) — list/getById/create/update/delete,
listNodes, listEventTypes, listTemplates, listRuns, latestRun, getRun. Types come from
the domain barrel; the SDK redefines nothing.
The canvas (apps/dashboard/src/routes/workflows*, components/workflow/)
Section titled “The canvas (apps/dashboard/src/routes/workflows*, components/workflow/)”React Flow supplies the generic hard parts (viewport, edge routing, handle hit-testing); domain
stays local. Connection legality is structural only — a cycle-closing edge refuses to attach
while being dragged. Selected node, viewed run, and expanded run-node all live in search params
(?node=, ?run=), so any state is a URL. Saving is explicit. The NodeInspector renders three
tiers: bespoke editors (condition rows on if only, the event picker, read_gmail’s nested filter
— see components/workflow/nodeParams/), the declared inputs schema (with format: 'skill' →
the real SkillPicker and format: 'connector' → a channel pin or destination picker), and an
“Advanced — raw params” JSON disclosure — a node accepts keys no schema declares, and templates
bake them. A dropped node arrives pre-filled from declared defaults (defaultsFromSchema), not
{}. The run view (Runs button) lists runs, shows per-node rows with per-output splits, skip
reasons in words, condition signals and the capped sample; canvas nodes wear count chips while a
run is viewed.
A skill param stores the skill’s ID, not its slug. skills is unique on (user_id, slug) and
resolveSkillSteering looks a ref up owner-scoped (findById first, then findBySlug), so a stored
slug names whichever skill the WORKFLOW’S OWNER happens to have under it — two accounts each owning
urls-to-links gave two live workflows the same ref and two different rubrics, with nothing in the
graph, the logs or the run view saying which. An id (skill_<hex>) is global, so it names one row
wherever it is read. Slug refs still resolve (old graphs, and ${skill:<id|slug>} in a step config),
and 20260829170000_skill_refs_to_ids rewrote the stored ones to the id each owner already resolved
— a slug naming nothing for that owner was left alone rather than guessed at. Two consequences worth
keeping: a template must bake no skill ref (WorkflowTemplates.test.ts enforces it — “Use
template” copies params verbatim, so a per-owner ref would follow the clone to someone else’s
rubric or to none), and every skillable node resolves through the ONE shared resolver, which is what
makes the @<version> pin and the kind guard apply to all three rather than to whichever node last
had them written out.
The run transport — how a drawn workflow actually fires
Section titled “The run transport — how a drawn workflow actually fires”Every fire path lands on the dedicated workflow-run pg-boss queue (mirroring
webhook-delivery) and executes through one seam, executeWorkflowRun: load the workflow
fresh (a graph edited between a cron registration and its tick runs the current graph, never a
payload snapshot — the drift job_schedules templates suffered from), guard, runWorkflow,
persist the run. The worker runs batchSize: 1 — serialization is a property here, not a
limitation (shared services, a credits balance, source cursors).
Sends carry retryLimit: 0, passed explicitly. A run is not blindly idempotent (LLM spend is
real money out) and failures belong in the run history where the run view explains them — but for a
long time this doc and the worker both said “no retry” while setting nothing, and pg-boss defaults
retry_limit to 2. Saying nothing bought the opposite of what the sentence claimed: a run that
hung past the 900 s expiry came back twice to hang again, so one stuck node could hold the queue for
45 minutes and bill three times for the work it did first. Alongside it, each node runs under a
deadline derived from that same expiry rather than picked (NODE_DEADLINE_MS, one minute
inside it) — so it can only ever end runs pg-boss was already killing, one minute earlier and with a
record naming the node that stopped. The losing promise cannot be cancelled, so what it reliably
frees is the QUEUE, not the node.
Stopping a run
Section titled “Stopping a run”Nothing on the platform can be killed: a run is one promise inside the API process, the model call
inside it is a fetch, and a JS promise cannot be interrupted. So a stop is cooperative, in the
same shape throughout (settle the row, let the worker discover it):
POST /api/workflows/:id/runs/:runId/cancel(SDKcancelRun, the Stop button beside Run) is one conditional write —queued | running → cancelled, with the reason aserrorandfinishedAtnow. 409 when there was nothing to stop: the run finished on its own first, and a cancelled row over a completed one would be a lie. A parked (waiting) run is stopped too — NEO spends most of its life waiting on squads — and the wake its squad or timer later sends is dropped as stale. Both this route and the card’sPOST /api/tasks/:id/main/stop(the card’s creator only; the live panel’s Stop button) go throughcancelRunTree, which stops the run’s SQUADS with it and drops the checkpoints of anything stopped while parked. The card’s “Open the run” link lands on/workflows/internal:main-agent:<id>?run=…: an ad-hoc id answersGET /api/workflows/:idwith the graph stamped on its latest run,readOnly.- The executor asks between nodes (
checkCancelled, one primary-key read viapeekStatus) and polls while a node works (CANCEL_POLL_MS, 3 s), racing the poll against the node the same way the deadline is raced. Winning aborts anAbortControllerwhosesignalridesNodeRunContext.signal→ the AI gateway binding (ResolveAiInput.signal, stamped on everychat/reasonthe binding makes) →LlmOptions.signal/AgentRequest.signal→ the Anthropic, Cline and Vercel adapters. A model call therefore returns within one round-trip of the click, not at the 14-minute deadline. What the signal does NOT reach: a scraper’s own timeout, a CDP session — those finish late, never wrong, because the engine records the node as skipped-cancelledregardless. - A queued run stopped before pick-up is refused by
executeWorkflowRunon the same peek, and the pg-boss job completes without executing. A stopped run runs no compensation (whoever stopped it already wrote what the subject should say) and commits no watermarks (the work is unfinished and must be re-read). Every node the loop never reached is recorded skipped-cancelledso the run view shows the whole graph. savenever downgrades a cancelled row (aCASEon the upsert forstatus,error,finished_at): the executor’s progress writes are already in flight when a stop lands, and the last of them must not put the run back torunning— or close it ascompletedover the reason. Node rows still replace, so the view shows where it stopped.
Order within the queue is not first-come: a manual fire is sent with a pg-boss priority so a
person’s click is picked up ahead of a cron backlog. Ordering only — serialization is untouched. The
trade is deliberate and worth stating: a cron tick can now wait behind an ad-hoc click. Crons are
not latency-sensitive and already skip when they overlap; a person staring at a button is.
Without pg-boss the enqueuer degrades to inline next-tick execution.
No transport can enqueue an invisible run. withQueuedRun wraps the dispatch rather than each
call site, so the route, the event dispatcher and the internal ad-hoc chains all write the queued
row before the message goes on the queue. A pg-boss CRON is the one fire that cannot go through it —
pg-boss creates that job itself from a payload stored at registration — and it is also the one
nobody is watching a screen for; it becomes visible when the worker picks it up. Do not “fix”
that asymmetry by putting a runId in the cron payload: the payload is replayed every tick, so all
of that workflow’s runs would upsert one row.
Per trigger kind (buildWorkflows’ runtime block wires all three at boot):
- Event —
WorkflowTriggerDispatchersubscribes to the domain event bus; its store re-derives bindings from each active graph viafindActiveByEventType+resolveWorkflowTriggers, so the graph stays the only binding. The event’s payload seeds the trigger node. - Schedule —
BossWorkflowSchedulerregisters one cron pertrigger.schedulenode, keyedwf.<workflowId>.<nodeId>on the shared queue (the same key mechanismPgBossJobRunneruses).PATCH /:idre-syncs on every update (activate registers, deactivate/edit re-derives), DELETE sweeps, and bootreconcilediffs thewf.-prefixed keys againstlistActiveScheduled()— only our prefix is touched. Guards mirror the old fire path: a queued tick on a deactivated workflow is skipped, and skip-if-previous-still-running holds for schedule fires viaruns.hasRunning— age-bounded, and that bound is load-bearing: a worker killed mid-run leaves a row claiming to be in flight forever, and an unbounded check would then skip every cron tick of that workflow until someone noticed by hand. Rows older than the queue’s own expiry are leftovers, not runs; boot sweeps them tofailedwith a sentence saying the server restarted. - Manual —
POST /api/workflows/:id/run(SDKrun(), the canvas Run button): the documented bypass, exactly asPOST /schedules/:name/runwas — no active requirement (testing a drawn workflow is the point), no overlap skip, 202 + the run view. Seeds every trigger node with one{ firedAt }item; an unseeded trigger emits nothing and its branch skips quietly. The 202 carries therunId, so the canvas opens that run rather than asking for the latest. A second click while one is still pending returns the SAME run (alreadyPending) instead of stacking a duplicate — not the overlap policy in disguise (that one is about two SCHEDULED fires racing an export target), but about a person who could not see the first click land.
Metering adapts the jobs credits meter per node: isMetered reads the registry descriptor’s
mode === 'ai', charges land per node under the run id. A metered node on the user’s OWN key
(runsOnOwnKey, asked of the gateway) skips the balance gate and its usage is stamped ownKey, so
nothing is debited — except the PLATFORM-KEY nodes (PLATFORM_KEY_NODES): tts, which spends the
platform’s KIE_API_KEY, and generate_image, which spends the managed Cline key
(MANAGED_LLM_API_KEY, google/gemini-2.5-flash-image), whatever the user connected. Both are
charged from the provider’s reported USD cost, not tokens. Watermarks commit through the shared
store, scoped by the run’s own user.
Org scope. A run’s orgId is the message’s, else the workflow row’s (stamped from the
session’s active org at POST / creation), else resolved at fire time from the owner via
resolveOrgId → resolveActiveOrg — the same run-time resolution processJob does on the job
engine. The fallback is load-bearing: rows created before the stamp and every internal-chain
message carry no org, and without it org-guarded nodes (best_of_tech, triage,
collect_invoices, the step-backed sinks) halt while org-scoped connector lookups quietly
resolve to nothing.
One-off runs — the ad-hoc chains
Section titled “One-off runs — the ad-hoc chains”A one-off carries its graph in the message (WorkflowRunMessage.graph) instead of pointing at
a workflows row. It saves under a synthetic internal:<kind>:<id> workflow id — the column has
no FK, by design — and never appears on the canvas, because these chains are per-subject: a saved
workflow would be either wrong or one-per-card. apps/api/workflow/internalChains.ts builds them.
| Chain | Id | Nodes |
|---|---|---|
| Handed to NEO | internal:main-agent:<taskId> | delegate or NEO agent → collect_squads → summarise → log_activity — see “NEO and squad kinds”; the squad itself is a child run under internal:squad:<runId> |
| Publish now | internal:publish-now:<channel> | publish → post_comment (post), publish alone (email) |
| Zen prompt | internal:zen:<promptId> | agent, tool-less |
Illustrate (POST /api/images/generate) | internal:image:<uuid> | generate_image — a query, a card (read through the actor predicate), or both, plus at most one reference image; stores ONE pub asset and reports its URL on main. It writes nothing onto the card: the dashboard previews it and attaches it with the card’s own write on “Use this image” |
Suggest next step (POST /api/tasks/:id/next-action, CRM cards only) | internal:next-action:<taskId> | agent (tool-less, one step, over the card + its last 10 timeline entries rendered by renderNextActionContext) → log_activity (runOnFailure, so an unreachable model still leaves a line on the timeline saying why) |
NEO’s filing suggestion (after POST /api/tasks creates a task / user story / page / custom card, no from:* tag) | internal:suggest-tags:<taskId> | agent (tool-less, one step, SUGGEST_TAGS_TOKEN_BUDGET, over the card + the org’s live project:* tags + the creator’s open cards holding sub-cards, numbered by renderTagSuggestionPrompt) → tag_suggestion (keeps only a pick naming one of those candidates, stamped on its params) → log_activity (metaFromItem: 'tagSuggestion'). NO runOnFailure: nobody clicked, so nothing to suggest — or no candidates at all, which skips the run — leaves no line. The editor’s TagSuggestionStrip applies (project tag / part_of link) or dismisses (meta.tagSuggestionDismissed) |
They drain from a SECOND queue, workflow-run-adhoc, with its own batchSize: 1 worker. Both
queues drain one run at a time; the split is what stops a click queueing behind a cron.
MANUAL_RUN_PRIORITY only reorders the pending set — it cannot preempt a node already running,
which may hold its queue for the full 14-minute deadline, while the publish watcher gives up after
60 seconds. createBossWorkflowRunEnqueuer picks the queue from the run’s id, so no caller can put
a one-off on the wrong one, and it throws if a message’s graph and id disagree.
countAhead (the run view’s queue position) is scoped to the run’s own queue for the same reason:
counting the other queue’s backlog would measure the wrong wait.
No overlap guard applies to an ad-hoc run. Two Publish-now clicks both run; what keeps them
from double-publishing is PublishNode’s atomic reviewed → posting claim, one row at a time. Do
not add a chain-level lock — it would turn a safe overlap into a refused click.
Where the outcome lands. NEO’s chains and Publish now report on the CARD (NEO’s on its
timeline, the link/contact/tts squads also on the card’s own fields); only the zen prompt reads its answer back out of the run, from the agent node’s output
sample — and so does Illustrate, from the generate_image node’s main item (a URL and a caption, well
under the cap). That is why the zen node raises sampleMaxBytes to 64 KB: capSample does not truncate an
item that alone exceeds the cap, it DROPS it, so an oversized answer would come back as an empty
sample and the surface would show nothing. The field is stripped whenever a graph is SAVED — by
createWorkflowDefinition on create and by PATCH on a graph edit, not just by the canvas — because
a saved workflow able to widen its own samples could write unbounded rows into
workflow_node_runs.
Errors that name a remedy
Section titled “Errors that name a remedy”A run’s failure used to name a condition in the code’s own vocabulary — best_of_tech requires an organisation scope — and leave the reader nowhere to go. A halt may now carry
fix: { label, href } (NodeFix), which runWorkflow copies onto the node record and the run
view renders as a link under the message.
The rule that makes it safe: the node that knows the cause declares the cure. Nothing downstream
pattern-matches a sentence to decide what to offer. That coupling has already cost us once outside
this subsystem — SocialTokenManager.notConnectedMessage ends with an instruction and
lib/publish.ts keeps a SELF_EXPLANATORY regex matching that literal to suppress its own, so a
reworded sentence in one app printed two contradicting instructions in the other.
Three things that are easy to get wrong here, each learned from production:
- Fix the message where it actually fires. Three org-scope halts were reworded by hand first;
prod then showed that of 34 failed runs, 32 were this guard across five node types, and the
three chosen accounted for three of them while
capture_comments— responsible for 26 — still saidrequires an orgId. The sentence now lives once (ORG_SCOPE_MESSAGE+ORG_SCOPE_FIX), so improving it improves all seven. Two spellings of one condition (requires an orgIdvsrequires an organisation scope) were the tell that per-site wording drifts. - The self-sourcing sinks say it through their base.
capture_comments,youtube_to_tasks,calendar_to_tasks,social_audienceandttsextendSelfSourcingNode, whosefail(message, fix)is the only way they report a failure — so a remedy cannot be dropped by forgetting to carry it.SelfSourcingNode.test.tspins that. - Only offer a page that can help. A dispatch failure points at
/channelsonly when the destination HAS one — gated onisInternalScheme, sincedashboard/sales/linkwrite to the platform’s own tables. Prod hit exactly the other case:Target 'dashboard' failedon a task INSERT, where no amount of reconnecting helps. An unregistered node type is given no fix at all, deliberately — it is a deployment gap, and inventing a link is worse than saying plainly that it is not on the user.
A failure must report what the database actually said. describeError flattens a
DrizzleQueryError — whose own message is the generic “Failed query: …” with the reason on
cause — and every node catch site goes through it. Five had drifted back to error.message
alone, which is what left a prod failure saying nothing but the SQL echoed back and turned a
one-line diagnosis into an investigation. Grep for
error instanceof Error ? error.message before adding a catch.
Text reaching the database is sanitised at the write boundary. A JS string is UTF-16 and can
hold half an astral character; a title cut to a length limit mid-emoji leaves a lone surrogate, Bun’s
driver writes 0xef 0xbf, and Postgres rejects the statement with 22021 invalid byte sequence
— losing the whole batch over one card. toStorableText (applied in DrizzleTaskRepository)
repairs unpaired surrogates and drops NULs. It belongs at the boundary, not per producer: text
arrives from scrapers, LLMs, Gmail, Notion, YouTube and whatever is added next, and there is one
database.
The schedules migration — done, and deleted
Section titled “The schedules migration — done, and deleted”migrateJobSchedulesToWorkflows ran at boot after the cron reconcile and translated each
job_schedules row into a graph: the RESOLVED chain (preset expanded, ${input:x} substituted
from the stored inputs) became a workflow created active under the schedule’s own slug, its
cron synced, and the old row then disabled through the same usecase the pause button used — so
exactly one engine fired each automation from that boot on. The slug was the idempotency key.
It has finished and the code is gone. Every row migrated (prod: 14 rows, none enabled, 11 of
them preset-bearing and all disabled), and the preset expansion it depended on no longer exists,
so a second pass could only ever have been a no-op holding a dead concept up. What survives in
apps/api/workflow/systemWorkflows.ts is ensureSystemWorkflows, which never touched presets:
it seeds the system automation (recurring invoicing) on a fresh environment — one that never
had the job_schedules row to convert — and re-syncs its cron on every boot.
The disabled rows are left in place. Nothing reads them; dropping the table is a separate change.
Part 2 — Publishing, and the boundary
Section titled “Part 2 — Publishing, and the boundary”The publish worker sees only ONE of a post’s two arms
Section titled “The publish worker sees only ONE of a post’s two arms”A post row carries a destination strategy in Task.destination.scheme, and it is the whole
difference between a post Studio publishes and one the user publishes themselves:
| arm | destination.scheme | who posts it | needs a connector |
|---|---|---|---|
| automated | automated | the publish worker, through the network’s API | yes |
| manual | manual | the USER, from the network’s own composer, prefilled | no |
PublishNode.findDue selects on destinationScheme = 'automated' — an exact match in both
findDueForPublishing and findAwaitingComment. So a manual row is unreachable by the publisher
structurally: there is no opt-out flag to invert and no branch to forget, which is why the manual
arm is expressed as this field rather than as a boolean beside it. apps/api/publishing/ contains
no manual-arm code at all, and PublishNode.test.ts asserts that by publishing an automated row
and a manual twin in one pass.
The manual arm is why the prefilled-composer mode exists: it is the only mode available on a network whose write API we may not use for this (X — the Developer Policy’s Pay to engage clause governs use of the API, so a composer the user submits themselves is outside it) or cannot get approved for (Meta Advanced Access, LinkedIn Community Management). It carries no ToS exposure and stores no credential.
Two statuses belong to it, and selectableStatuses(entity, arm) keeps each arm out of the other’s:
handed_off— the composer was opened with this text. Nobody has confirmed it went out and nobody can; there is no receipt to poll for.posted_by_hand— terminal success as asserted by the operator.
posted_by_hand is permanently distinct from posted, and must stay so: posted means an API
accepted the post and handed back an id, this means a person said so. Anything that reconciles,
audits or reports on publishing has to be able to tell them apart. Conversely reviewed — the
publish TRIGGER — is refused on a manual row, where it would sit forever meaning nothing while
reading on the card exactly like a row about to go out.
The transport is composerUrlFor (packages/platform-domain/src/tasks/destinations/composerUrl.ts),
which returns the deep link and the list of fields it cannot carry. No composer accepts media
and most accept only a body, so a builder returning a bare URL would silently drop an attached
image; a URL over MAX_COMPOSER_URL_LENGTH returns no link at all rather than one the platform
would truncate. These are undocumented third-party endpoints — verify them against the live
composer before trusting the table in that file.
Monitoring
Section titled “Monitoring”GET /api/workflows/queue-stats — admin-only (ADMIN_EMAILS), live depth + 24 h rollup per queue
via IQueueStats (the runtime-managed pgboss schema is read-only — never add a drizzle migration
for it). It reports on workflow-run, workflow-run-adhoc and webhook-delivery. It used to live
at GET /api/jobs/queue-stats and, while there, its allowlist named the job queue and omitted both
run queues — so the card reported on the queue that was about to be deleted and nothing about the
ones carrying work.
/api/health no longer reports systemSchedulesHealthy. It had become a hardcoded true once
system automations were workflows seeded by ensureSystemWorkflows, so it answered “healthy”
whatever the truth was; a monitor keyword-matching on it was being reassured by a constant.
Runs are read through the run view.
Domain boundary
Section titled “Domain boundary”Unchanged and enforced twice: packages/platform-domain has only zod as a runtime dep, and its
eslint config blocks drizzle-orm, pg-boss, hono, Node built-ins and any apps/* import. The
workflow entities are additionally browser-safe — the dashboard imports the barrel directly.
Part 3 — What the Job engine left behind
Section titled “Part 3 — What the Job engine left behind”The deletion is complete. apps/api/jobs/, packages/platform-domain/src/jobs/’s
job-specific half, the SDK JobClient, the dashboard’s job surfaces, the CLI --jobs flag and
both tables (jobs, job_schedules) are gone. The surfaces that rode it — Publish now, zen
prompts, and the since-retired Run agent — enqueue ad-hoc workflow runs.
What survives from that module, and why:
apps/api/work/— the wiring hub both engines always shared, renamed frombuildJobs. One AI gateway, one connector registry, one watermark/cursor store, one export-target map, the pg-boss instance and the sink node instances. It was never an engine.SINK_INPUTS(the eight sink input schemas) andInputFieldSchema, which moved tocommon/because two domain modules import it across what used to be thejobs/boundary.IQueueStats, moved toworkflows/ports/— queue health was never job-specific.
Phase 2 finished the job. The nine sinks were the last classes written against the old
contract: each took a WorkflowContext and returned a StepResult, and a StepBackedNode wrapper
translated that to INode at run time. They now extend SelfSourcingNode
(workflows/ports/SelfSourcingNode.ts) — run(params, ctx) returning a NodeResult, with three
helpers stating the only three outcomes a sink has: done(message, summary) (ran, and the summary
rides on the one item it emits on main), stop(message) (nothing to do — a green halt, never with
a fix), fail(message, fix?). With nothing left implementing it, the whole
packages/platform-domain/src/jobs/ tree was deleted: IWorkflowStep, BaseWorkflowStep,
WorkflowContext, JobStep, JobStepCatalog and JobSteering had no consumer outside it. The
nine live in <vertical>/nodes/*Node.ts.
Two rules survive that deletion and are easy to lose:
- A sink must emit that one summary item. An empty output makes the executor SKIP everything
downstream, and
publish → post_commenthas to sequence. - Spend is reported through
billableNodeUsage, notNodeResult.usagedirectly. The executor debits every entry it is handed; a TTS card that fails to render reports zero tokens and zero cost, and passing that through opens a 0-credit ledger debit per failed card.
Ad-hoc runs are the replacement pattern, and they predate the deletion: link enrichment,
contact enrichment and TTS already ran this way. A one-off carries its graph in the message, saves
under a synthetic internal:<kind>:<id> workflow id (the column has no FK by design), never
appears on the canvas, and drains from workflow-run-adhoc so a click never queues behind a cron.
The evidence ledger
Section titled “The evidence ledger”Contact and company enrichment used to write every field the model returned, under one blended
confidence. With several sources (a provider record, a profile page, a search snippet, the operator’s
notes) that cannot say which to trust, and a guess landed on the card looking like a fact. The ledger
(task_facts, a table beside tasks) keeps one row per (field, value) claim: what was seen, where,
what it is worth, and what became of it. The agent records what it saw; the ledger prices it — no
model is asked for a confidence.
- Evidence kinds are derived by code (
workflow/nodes/factObservations.ts), each with a fixed price (crm/factLedger.ts):provider.exact90 (matched on a typed LinkedIn URL / email / domain) ·provider.name40 + 5 × the provider’s likelihood ·card.title80 ·source.page85 when the page is certainly the entity (a company’s own domain, a person’s typed profile URL), else 65 ·mail.signature75 / 85 ·mail.header75 (the person’s own emails: 85 when the signature’s company matches the sender’s own email domain — an independent witness that lifts the whole parse) ·notes.stated65 (notes are text about the person — a recruiter’s message names the client beside the agency) ·source.snippet55 (the identity is a guess) ·model.inferred25. A model-returned value is traced to the texts the run read by containment; nothing contains it ⇒model.inferred.card.link90 is the one kind no run produces: a person links alinkcard whose URL IS a LinkedIn/in/or X profilecontact_ofa contact (crm/profileLink.ts, REST and MCPlink_records), and the profile fills the contact’s emptylinkedinUrl/xHandle(a different typed value makes it a suggestion). A signature’s LinkedIn and X profile links are signature claims like its role. - Bands: ≥ 80 VERIFIED · ≥ 50 PROBABLE · below, POSSIBLE. A second distinct source agreeing adds 5.
- Verdicts per field: a typed value is never overwritten (a claim equal to it is
appliedas corroboration, a different one isproposed). With no typed value only a VERIFIED claim isapplied— and only those reach the card. PROBABLE / POSSIBLE areproposed. A contradiction holds: two different values both ≥ PROBABLE within 20 points are allheld— neither written, no score lowered. A dismissed value is never suggested again; a re-run supersedes its own stale open rows. - API:
GET /api/tasks/:id/facts?scope=open|all,POST …/facts/:factId/accept|dismiss. Accept writes the value onto the card (replacing a typed one — a person choosing is the one thing allowed to), supersedes the field’s other open claims and logs a timeline line; both are one conditionalUPDATE, so two clicks cannot both win (409). - UI: a “Suggestions” panel under the contact / company editor; the whole ledger is
?evidence=all. - Ledger fields: contact — firstName, lastName, company, role, location, email, phone, linkedinUrl;
company — industry, size, location, linkedinUrl.
summaryandtagsare descriptive, not claims.
Not wired yet: MCP get_task does not list a card’s suggestions, and accepting a first/last name does not
rename a card titled after an email address.
