Workflows in code
defineWorkflow declares a workflow in TypeScript. It returns an ordinary agent config, so a workflow is built, pushed, versioned, deployed, and connected exactly like a code agent. What makes it a workflow is that its code is the platform’s workflow runner and its behavior is the graph you declare.
Use code when you want the graph reviewed in pull requests, tested in CI, and shared between environments. The graph is the same document the Builder produces.
Project layout
ticket-triage/├── cef.config.ts ← export default defineWorkflow({ … })├── src/│ └── prompts.ts ← prompts, SQL, and expressions as constants├── cubbies/│ └── triage/│ └── 001-init.sql ← one directory per cubby, numbered migrations├── deployments/│ └── default.jsonc ← which version is live├── test/│ └── triage.test.ts├── package.json└── tsconfig.jsonDependencies:
pnpm add @cef-ai/agent-sdk@^5.8.0pnpm add -D @cef-ai/cli@^2.8.0 @cef-ai/testing@^3.3.5 typescript vitestThe workflow runner ships inside @cef-ai/agent-sdk; you do not install or point at it.
A complete workflow
import { defineWorkflow } from "@cef-ai/agent-sdk/config";import { CLASSIFY, SCHEMA } from "./src/prompts.js";
const X = (col: number) => col * 320;
export default defineWorkflow({ id: "ticket-triage", version: "0.2.0", goal: "Classify a support ticket, escalate urgent ones, and record the result", models: { llm: "https://cdn.example.com/1234/models/my-llm/1.0.0/model.json", }, cubbies: [{ alias: "triage", migrations: "./cubbies/triage" }], nodes: [ { id: "ticket", kind: "trigger", label: "New ticket", position: { x: X(0), y: 0 }, params: { sample: JSON.stringify({ ticketId: "T-1", text: "I was charged twice" }) }, }, { id: "classify", kind: "model", label: "Classify", position: { x: X(1), y: 0 }, params: { alias: "llm", input: { messages: [{ role: "user", content: CLASSIFY }], max_tokens: 128, response_format: { type: "json_schema", schema: SCHEMA }, }, into: "classification", }, }, { id: "parse", kind: "transform", label: "Read the classification", position: { x: X(2), y: 0 }, params: { expr: "({ ...item, ...JSON.parse(item.classification.text) })" }, }, { id: "route", kind: "branch", label: "Urgent?", position: { x: X(3), y: 0 } }, { id: "escalate", kind: "human", label: "Escalate", question: "=Urgent {{ $json.category }} ticket {{ $json.ticketId }}. Take it?", position: { x: X(4), y: -160 }, }, { id: "save", kind: "cubbyExec", label: "Record the result", position: { x: X(5), y: 0 }, params: { alias: "triage", sql: "INSERT OR REPLACE INTO triage_tickets (run_id, ticket_id, category, priority) " + "VALUES ('{{ $runId }}', ?, ?, ?)", args: ["={{ $json.ticketId }}", "={{ $json.category }}", "={{ $json.priority }}"], }, }, { id: "result", kind: "output", label: "Result", position: { x: X(6), y: 0 }, params: { result: { category: "={{ $json.category }}", priority: "={{ $json.priority }}" }, resultTypes: { category: "string", priority: "string" }, }, }, ], edges: [ { from: "ticket", to: "classify" }, { from: "classify", to: "parse" }, { from: "parse", to: "route" }, { from: "route", to: "escalate", when: { field: "priority", op: "eq", value: "urgent" } }, { from: "route", to: "save" }, { from: "escalate", to: "save" }, { from: "save", to: "result" }, ],});export const CLASSIFY = "=You triage support tickets. Classify the ticket below.\n\n{{ $json.text }}";
export const SCHEMA = { type: "object", properties: { category: { type: "string", enum: ["billing", "bug", "question"] }, priority: { type: "string", enum: ["low", "normal", "urgent"] }, }, required: ["category", "priority"],};defineWorkflow fields
| Field | Required | What it is |
|---|---|---|
id |
yes | The workflow’s id. The agent id is <agentServicePubkey>:<id>. |
version |
yes | Semver. Bump it for every push. |
nodes |
yes | The steps. See Steps. |
edges |
yes | { from, to, when?, loop? }. |
goal |
One line on what the workflow does. Also the card description when card is omitted. |
|
models |
Alias → model.json URL for every model step. |
|
cubbies |
{ alias, migrations } for cubbies the workflow declares. See Cubbies. |
|
schedules |
Cron triggers. See Triggers. | |
card |
{ name, description }. Defaults to the id and goal. |
|
idleTimeout |
How long a run’s Job stays open with nothing happening. Default "30m". |
|
runner |
Path to a different runner build. Leave it unset. |
Nodes
Each node is { id, kind, label?, position?, params?, use?, emit?, question? }. use and emit are for agent steps, question for human steps.
Give every node a position. The runner ignores it, but the Builder draws a code-authored workflow with your positions, left to right if you lay them out that way.
Edges are typed against node ids
defineWorkflow infers the node ids as literal types, so an edge naming a step that does not exist is a compile error:
error TS2820: Type '"reslt"' is not assignable to type '"ticket" | "classify" | "result"'. Did you mean '"result"'?Declare nodes inline in the call (or as const) to keep this check.
Keep long text out of the graph
Prompts, SQL, and expressions read better as constants in src/ and imported, as above. The graph keeps their values; nothing else changes.
Scope
A workflow is connected on the scopes it requires: default unless you say otherwise. To run in another scope, override requiredScopes:
const workflow = defineWorkflow({ /* … */ });export default { ...workflow, requiredScopes: ["support"] };What cef build checks and produces
cef build loads cef.config.ts, runs the same validation the runner runs before a run, and refuses the build on any fatal problem, with every problem listed:
- duplicate step ids, unknown kinds, edges to unknown steps, no trigger;
- a step nothing leads to;
- an agent step without
use; - a model step whose alias is not in
models, or amodelsvalue that is not a.../<bucket>/models/<name>/<version>/model.jsonURL; - a schedule whose
idis not a trigger withmode: "schedule"; - templates without the leading
=, cycles without a loop edge, invalid human-step params, invalid regions.
It writes dist/<id>/:
| File | What it is |
|---|---|
bundle.js |
The workflow runner, with your model references baked in. |
manifest.json |
The agent manifest. The graph is in params.graph as its default value. The runner’s own runs cubby is declared beside yours. |
Then ship it like any agent:
cef push --bucket <bucketId> --as-pubkey <agentServicePubkey>cef cubby push --bucket <bucketId> # when you added or changed a migrationcef deploySee Push and deploy. After the first deploy, open the workflow in ROC and press Run on its Executions tab: ROC connects it to the vault first and asks you to approve the connection. See Connect to a vault.
Code and the Builder
The graph is one document in two editors.
From the Builder to code. On the canvas side rail, Export as code produces a cef.config.ts with defineWorkflow: id, version, goal, card, every node with its params and position (snapped to a 16 px grid), every edge, and schedules. Save it beside a package.json and push it with cef push.
From code to the Builder. A workflow pushed with cef push appears in the Agent Service’s Workflows list and opens in the Builder read-only, marked from the repo · read-only: you edit it in the repo and push. Its steps are drawn at your position values. You can run it, read its runs, and run evaluations from ROC.
What travels in each direction:
| Builder → code (Export) | Code → Builder | |
|---|---|---|
| Steps, params, edges, conditions, loops | yes | yes |
| Positions and labels | yes | yes |
| Schedules | yes | yes |
| Builder-only step forms (Filter, Edit fields in Fields mode, Memory, Cubby) | exported as the kinds they compile to (branch, transform, remember/recall/relate, cubbyQuery/cubbyExec) |
drawn as those kinds |
| Webhook URLs and keys | no: installed by Deploy in the Builder | no |
| Connection access for connector steps | no: written by Deploy in the Builder | grant it through the vault API, see Connectors |
| Pinned widget | no: part of the canvas | no |
Model declarations (models) |
no | yes |
Versions differ too: the Builder’s Deploy picks the next patch version itself, while in code you set version.
Test it
Run the graph against the real runner in memory with @cef-ai/testing, mocking the agents it calls. See Test a workflow.