Skip to content

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.json

Dependencies:

Terminal window
pnpm add @cef-ai/agent-sdk@^5.8.0
pnpm add -D @cef-ai/cli@^2.8.0 @cef-ai/testing@^3.3.5 typescript vitest

The workflow runner ships inside @cef-ai/agent-sdk; you do not install or point at it.

A complete workflow

cef.config.ts
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" },
],
});
src/prompts.ts
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 a models value that is not a .../<bucket>/models/<name>/<version>/model.json URL;
  • a schedule whose id is not a trigger with mode: "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:

Terminal window
cef push --bucket <bucketId> --as-pubkey <agentServicePubkey>
cef cubby push --bucket <bucketId> # when you added or changed a migration
cef deploy

See 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.