Skip to content

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.

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.

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.

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 dispatch on 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. skipped is a first-class outcome (never rendered red), with a recorded skipReason (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.
  • runOnFailure closers run whenever the pass did not reach them. A node flagged runOnFailure owns a subject’s terminal write (update_link, update_contact). It executes with an empty batch and context.runError set in TWO cases: after a failure (the run stays failed), and at the end of a completed run that skipped it for no_input / upstream_skipped — the case a green run used to strand: read_url RAN and fetched nothing, enrich skipped, the closer skipped behind it, and the link sat on enriching with a 142 ms completed run behind it. The runError then 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. Its skipped row is replaced by the ran row, 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 resume delivery whose row is not waiting or queued is 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. runWorkflow totals each node’s usage and refuses to START the next node once the budget is spent: the run fails with the node named, and its runOnFailure closers still run (NEO’s log_activity writes it onto the card). On a resume, replayed nodes keep the usage they reported on the earlier pass — executeWorkflowRun reads 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; AgentNode hands the loop the tighter of that and its own tokenBudget param. 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.
  • priorCalls counts 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_approval holds the call for a person — only in a node that can ask one.

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 in tasks/lessons/INDEX.md and the ## headings of CLAUDE.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).

KindGraphPicked by
researchplan (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 toolNEO
codingplan (repo read tools, recall, recall_read) → work (repo tools, opens ONE draft PR) → CI ⇄ fix, at most 2 fixes → reviewNEO
workflowwork (create_workflow, saved switched off)NEO
contentplan (planner, no tools) → work (worker, scrape, get_task, list_destinations, create_post_draft, recall, recall_read) → review (reader)NEO
boardwork (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
imageone 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_UNAVAILABLENEO
ttsttsthe card
linkread_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
contactfind_linkedin_profile → read_contact_mail → enrich_provider → enrich_contact → update_contactthe card
companyread_company_site → enrich_provider(subject: company) → enrich_company → update_companythe 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 owner fact;
  • the person’s repository memory: CLAUDE.md, tasks/*.md, tasks/lessons/*.md of their default github:// 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-lite embeddings when VOYAGE_API_KEY is 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_memory tool (as does the board squad; at most 3 per run); Claude has the MCP remember tool; POST /api/memories/import parses a pasted Claude / ChatGPT export by code (no model), one proposal per line. All of them hold IMemoryProposals only — no status, no scope, no decide — handed over through proposalsOnly, so the methods are absent at run time too.
  • Deciding: POST /api/memories/:id/accept|dismiss, DELETE /api/memories/:id and a person’s own POST /api/memories are 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 and memoryTextKey).
  • Reading: the review digest (loadReview’s memories, the inbox entry), the context pack (applied only, after the profile), and recall (kind memory: 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_activity
otherwise: NEO → did it delegate? ─true→ collect_squads → summarise ─┐
└false (its one question) ────────────┴→ log_activity

A 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 ToolApproval on 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_subscriptions row 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: subscribeNeoPush reads 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 in description and ![](…) sources in body are read, capped at 4 images of up to 5 MB each.
  • An own-asset URL is read straight from storage. A priv key 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_file returns a window of at most 300 lines / 20k characters, with totalLines and a next hint. startLine/endLine read another part. The text is unnumbered, so find strings copy cleanly, and edits still resolve against the whole file.
  • repo_list_files past 300 files answers with the sub-folders and their counts (plus the files directly in the folder), so the next call can name a prefix.
  • The coding Plan runs on the worker role: 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 what POST /api/workflows calls too, so the tool can’t drift from the route. It never saves active: 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 agent node must list its tools. Without the list it would get every tool, publishing included, and the canvas does not show that list. It may not name create_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 workflow squad. create_workflow is not in the shared map a node without tools receives. It sits in AgentNode’s optInTools and 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.

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_contacts uses the same word Claude.ai does. create_task is 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, so params.tools filtering, the tool policy and the trace all keep working rather than each being taught about a parallel “remote tool” concept.
  • buildMcp returns ONE buildServerFor, shared by the HTTP route and the agent, so the connector and the agent cannot be wired to different repositories.
  • Late-bound (the codingDispatch shape): the workflow registry is built long before buildMcp in index.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.

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 userId in 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.

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.

NodeCategory · modeOutputsNotes / gating
trigger.manual / .schedule / .eventtrigger · detmainalways registered; .schedule takes cron, .event takes eventType
ifdecide · dettrue / falseone condition group per node; the only node holding conditions
read_gmail / read_driveingest · detmainshare performRead; watermark scoped to the workflow id; items carry externalId = provider message id
read_urlingest · detmainsecond-hop page fetch; canonical-URL dedupe, boilerplate/thin-page drops, all counted
read_notioningest · detmainone shape now — the step used to write items AND rows for the same records
agentenrich · aimaintool-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
enrichenrich · aimain / declinedrows as items; a FAILURE leaves on neither output (retry by construction)
analyzeenrich · aimain / droppedpartitions instead of dropping — a dropped item can finally be tagged, ending the re-read-forever bug
triagedecide · aiactionable / dismissedcards 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_driveact · detmainafter 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_invoicesdecide · hybridinvoices / rejectedfree gate first, LLM only for the ambiguous band; gate-only without channels
best_of_techingest · hybridmain / picksfiles 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
dispatchact · detmainroutes 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
tagact · detmainlabels 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_placeact · detmaina 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_linkact · detmainwrites 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_profileingest · detmain / declinedthe 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_contactenrich · aimain / declinedone 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_contactact · detmainwrites 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_mailenrich · detmainthe 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_providerenrich · detmain / declinedthe 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_siteingest · detmain / declinedthe 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_companyenrich · aimain / declinedone 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_companyact · detmainwrites 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_contactsact · detcontacts / messagesthe 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_tasksingest · detmainthe 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_slopenrich · aimain / clean / declinedruns 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_taskact · detmainwrites 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_parentact · detmainthe 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
delegatedecide · detmainstarts 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_squadsingest · detmainreads 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_checksdecide · detpass / failthe 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_activityact · detmainappends 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_invoicesingest · detmainthe 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_reminderenrich · aimain / declinedone 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_reminderact · detmainfiles 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_emailact · detmainemails 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 — ” (item 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 — actionable and dismissed. 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_invoices does 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 dispatch never acts on a verdict nobody made, and it still reaches tag, 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_cursors holds the per-calendar sync token. A separate table from ingestion_watermarks, not a column on it: that table’s contract is monotonicity (GREATEST on conflict), a token is an opaque string with no ordering, and — unlike a watermark — a token must be clearable, since Google answers 410 GONE once it ages out.
  • The :farEdge watermark 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 todo card 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_key permits only one — so the calendar that lost that race must not retire it. metadata.calendar carries 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_at on 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.

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.

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.

Mounted at /api/workflows (createWorkflowsApp); static paths registered before /:id:

MethodPath
GET/nodesregistered descriptors — the palette
GET/event-typesevent catalog + declared payload fields
GET/templatesclone-able starting points, registry-gated
POST / GET / GET /:id / PATCH /:id / DELETE /:idCRUD; PATCH validates graph + trigger presence only when active: true
GET/:id/runsrun headers, newest first (limit 25)
GET/:id/runs/latestmost recent completed run, nodes included — the field picker’s sample source
GET/:id/runs/:runIdone 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.

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 (SDK cancelRun, the Stop button beside Run) is one conditional write — queued | running → cancelled, with the reason as error and finishedAt now. 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’s POST /api/tasks/:id/main/stop (the card’s creator only; the live panel’s Stop button) go through cancelRunTree, 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 answers GET /api/workflows/:id with the graph stamped on its latest run, readOnly.
  • The executor asks between nodes (checkCancelled, one primary-key read via peekStatus) 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 an AbortController whose signal rides NodeRunContext.signal → the AI gateway binding (ResolveAiInput.signal, stamped on every chat/reason the 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-cancelled regardless.
  • A queued run stopped before pick-up is refused by executeWorkflowRun on 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-cancelled so the run view shows the whole graph.
  • save never downgrades a cancelled row (a CASE on the upsert for status, 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 to running — or close it as completed over 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 — WorkflowTriggerDispatcher subscribes to the domain event bus; its store re-derives bindings from each active graph via findActiveByEventType + resolveWorkflowTriggers, so the graph stays the only binding. The event’s payload seeds the trigger node.
  • Schedule — BossWorkflowScheduler registers one cron per trigger.schedule node, keyed wf.<workflowId>.<nodeId> on the shared queue (the same key mechanism PgBossJobRunner uses). PATCH /:id re-syncs on every update (activate registers, deactivate/edit re-derives), DELETE sweeps, and boot reconcile diffs the wf.-prefixed keys against listActiveScheduled() — 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 via runs.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 to failed with a sentence saying the server restarted.
  • Manual — POST /api/workflows/:id/run (SDK run(), the canvas Run button): the documented bypass, exactly as POST /schedules/:name/run was — 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 the runId, 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.

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.

ChainIdNodes
Handed to NEOinternal: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 nowinternal:publish-now:<channel>publish → post_comment (post), publish alone (email)
Zen promptinternal: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.

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 said requires 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 orgId vs requires 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_audience and tts extend SelfSourcingNode, whose fail(message, fix) is the only way they report a failure — so a remedy cannot be dropped by forgetting to carry it. SelfSourcingNode.test.ts pins that.
  • Only offer a page that can help. A dispatch failure points at /channels only when the destination HAS one — gated on isInternalScheme, since dashboard/sales/link write to the platform’s own tables. Prod hit exactly the other case: Target 'dashboard' failed on 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.

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:

armdestination.schemewho posts itneeds a connector
automatedautomatedthe publish worker, through the network’s APIyes
manualmanualthe USER, from the network’s own composer, prefilledno

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.

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.

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 from buildJobs. 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) and InputFieldSchema, which moved to common/ because two domain modules import it across what used to be the jobs/ boundary.
  • IQueueStats, moved to workflows/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_comment has to sequence.
  • Spend is reported through billableNodeUsage, not NodeResult.usage directly. 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.

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.exact 90 (matched on a typed LinkedIn URL / email / domain) · provider.name 40 + 5 × the provider’s likelihood · card.title 80 · source.page 85 when the page is certainly the entity (a company’s own domain, a person’s typed profile URL), else 65 · mail.signature 75 / 85 · mail.header 75 (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.stated 65 (notes are text about the person — a recruiter’s message names the client beside the agency) · source.snippet 55 (the identity is a guess) · model.inferred 25. A model-returned value is traced to the texts the run read by containment; nothing contains it ⇒ model.inferred. card.link 90 is the one kind no run produces: a person links a link card whose URL IS a LinkedIn /in/ or X profile contact_of a contact (crm/profileLink.ts, REST and MCP link_records), and the profile fills the contact’s empty linkedinUrl / 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 applied as corroboration, a different one is proposed). With no typed value only a VERIFIED claim is applied — and only those reach the card. PROBABLE / POSSIBLE are proposed. A contradiction holds: two different values both ≥ PROBABLE within 20 points are all held — 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 conditional UPDATE, 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. summary and tags are 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.