Skip to main content
Write a workflow shape of your own as pure code, and the runtime records and runs it like the shapes it ships. Use it when a pipeline, a review loop or a dag doesn’t fit the way your team moves work. For the shapes that ship, start from the built-in pipeline. The contract gives your graph type frozen data and graph lookups, and never a file, a model, a process, a clock or storage. The smallest graph type is two nodes and one edge, defined as JSON data:
examples/custom-graph.ts (excerpt)
compileGraph(graphType, definition) validates the definition before it calls the graph type, and the compiled graph’s definition field holds a frozen copy with its canonical JSON and a SHA-256 digest. A definition needs:
  • A stable id and a positive definitionVersion.
  • Graph data, whatever your type reads.
  • Nodes with stable id values and data, and edges with stable id, source, target and data.

Implement the graph type

A graph type has a stable kind, a positive version and one compile method, which receives the validated, frozen definition and a GraphKernel for node and neighbour lookups:
examples/custom-graph.ts (excerpt)
compile returns pure operations: reduce and decide receive frozen copies of their inputs and must return JSON data; the same definition and events must give the same state and commands. decide returns zero or more dispatch commands, or exactly one pause, complete or fail. An empty decision means start nothing new, and the executor accepts it only while a recorded attempt is in flight; without in-flight work it fails instead of asking forever. position is the stable identity of one requested node occurrence, unique within a decision; once the history records a dispatch, decide must not return it again, and a later event can make the same node dispatchable as a new occurrence with its own position. An optional validateNodeResult(nodeId, result) runs before the executor saves a completion, with a frozen copy of the result. null accepts it; an issue with string code, path and message records node-failed with RESULT_INVALID. A malformed return or an exception propagates as an error. GraphBindings<Requirements> maps a graph requirement to a host binding type: a required memory requirement to a required Memory binding, an unused one to a type with no memory binding. This layer never creates, injects or executes a binding.

Describe the graph

describe() declares what a host inspects before anything runs: phases, nodes, edges, input and output contracts, policies, execution lanes, requested permissions and bounds:
examples/custom-graph.ts (excerpt)
The description names every definition node once in a phase and keeps the definition’s node order; compileGraph supplies the edges list in the definition’s edge order. Each node’s lane must exist in executionLanes, and a node’s phaseId must name the phase that lists it. Each bound is known or unknown: use a known bound only when the graph can calculate the value, and an unknown bound with a reason when it can’t. Don’t invent a maximum or a concurrency value. validateGraphDescription(unknown) validates a description that didn’t come from compileGraph and returns it frozen; invalid data, including JSON nested more than 256 levels, throws GraphValidationError.

Prove conformance

runGraphTypeConformance checks a fixture without a test framework. The fixture declares the expected initial state and command, then the expected state and command after every event prefix:
examples/custom-graph.ts (excerpt)
The kit compiles the graph type in separate instances and calls each operation more than once. It checks that the initial state, every event-prefix state, every ordered command and the declared bounds stay the same, that invalid definitions fail, and that your code leaves the caller’s definition and events unchanged. It counts every dispatch in the trace and the largest dispatch set in one decision, and a declared dispatch or fan-out maximum can’t be below those. It doesn’t infer maxConcurrency, since the trace doesn’t show which attempts overlap; the graph declares that cap or reports it unknown. Each expected decision must hold only new requests, and when the final decision is exactly complete, the observed total must meet a known dispatch minimum. Run the file with npx tsx custom-graph.ts:
Output
The kit passed, the folded state is done, the next decision is complete, and the plan’s bounds are the two dispatches the description declared.

Build a callable team

team(config) returns a Job: callable work for several agents with one task and separate answers. It doesn’t use the stored graph executor, and passing it to run() doesn’t give it replay. For saved room messages and turns requested by mentions, compile teamGraphType as Team conversation shows. The config has: The result, TeamResult in the outcome’s data, holds the task, each member’s name, role and outcome, whether the work was integrated, and the review’s outcome where there was one. While a callback review waits, it also holds the question’s requestId. In a Git workspace, each agent gets the task and its standing brief in its own worktree through isolated(), and when an agent passes its changes merge into your branch under the merge lock all isolated() jobs share. Outside a Git repository, isolated() warns and runs in the shared workspace, so a passing team result doesn’t prove a merge happened; team() adds no merge or lease of its own. review takes a review panel or a callback gate. A panel receives the completed team result as its previous outcome, so its reviewers see the task and each member’s outcome. A callback gate posts its question to the run’s callbacks client and pauses the team until someone answers. The question’s input holds the gate’s own input, where the team sits in the graph, and the task with each member’s outcome. So a team that runs again, for example in the next turn of a loop, asks a new question, and two teams never share an answer. Start the run again with recordTo and resume: true over the same stored callbacks client. Members that finished don’t run again, and the team reads the answer. An answer with approved set to false fails the review, with its note as the summary when it has one. Any other answer passes the review. The team returns the usual Outcome, with a TeamResult in its data. The status is pass only when every agent passes and the configured review passes; a member or review failure returns fail with all member results preserved, a pause returns paused with its reason, and a merge conflict returns fail. Before it runs, the team checks that task is a non-empty string, that agents has at least one member with a unique name, that every member has a non-empty role, brief and engine binding, and that a configured review follows the panel or callback contract.

Failure

  • GraphValidationError from compileGraph: an empty identifier, a duplicate node or edge id, or an edge that names a node that doesn’t exist. No node can run from this layer.
  • RESULT_INVALID from validateNodeResult: the executor records node-failed and saves no completion.

Limits

  • An outside graph type is trusted package code. It can import modules and cause side effects on its own; the conformance kit checks public behaviour and doesn’t stop side effects.
  • This contract doesn’t schedule nodes, store runs or execute work. The graph executor runs nodes and stores runs.
examples/custom-graph.ts

Next steps