Write an agent
This group covers code agents: writing one, handling event streams, getting structured output from a model, and testing and debugging it. How a code agent calls a model is on Models.
A code agent is TypeScript you write with @cef-ai/agent-sdk. You declare what it uses in cef.config.ts, write its handlers as engagement classes, then ship it with cef build → cef push → cef deploy. It runs in a sandboxed V8 isolate, inside the vault of whoever connected it.
Code agent, LLM agent, or workflow
| Choose | When |
|---|---|
| A workflow | The work is a sequence of steps (trigger, model, agent, person, cubby, connector) that you want to see, edit in the ROC Workflow Builder, and evaluate. |
| An LLM agent | A prompt describes the work: instructions, a model, and tools are enough. See LLM agents. |
| A code agent | You need logic a graph or a prompt does not express well, such as custom parsing, loops over external APIs, or a conversation with its own state machine. |
A workflow can call a code agent as one of its steps, as it calls an LLM agent, so you can combine them. See Agents and workflows.
1. Scaffold
npx @cef-ai/cli init my-agentcd my-agentnpm install @cef-ai/agent-sdk@5.8.0npm install -D @cef-ai/cli@2.8.0 @cef-ai/testing@3.3.5cef init writes a hello-world project: cef.config.ts, src/agent.ts, migrations/, deployments/, a widget, and a test. The flags are listed in the CLI reference.
Your tsconfig.json needs "experimentalDecorators": true. The scaffold sets it.
2. Write an engagement
An engagement is a class whose methods handle events. When an event reaches a connected agent, the platform picks one engagement and creates a Job for it. That engagement handles every event in the Job until the Job ends.
import { Engagement, OnEvent, OnStart, OnClose, type Context, type Event } from "@cef-ai/agent-sdk";
@Engagement({ id: "default", goal: "Reply to a user message and remember it" })export default class HelloAgent { @OnStart async onStart() { console.info("started"); }
@OnEvent("user_message") async onMessage(event: Event<{ text?: string }>, ctx: Context) { const text = typeof event.payload?.text === "string" ? event.payload.text.trim() : ""; if (!text) { await ctx.vault.publish("reply", { text: 'send { "text": "..." }' }); return; } const now = Date.now(); await ctx.cubby("history").exec( "INSERT OR IGNORE INTO messages(id, text, ts) VALUES (?, ?, ?)", [event.eventId ?? String(now), text, now], ); await ctx.vault.publish("reply", { text: `you said: ${text}` }); }
@OnClose async onClose(_ctx: Context, reason: string) { console.info("closing", { reason }); }}| Decorator | Applies to | Effect |
|---|---|---|
@Engagement({ id, goal }) |
class | Names the engagement. id must match [a-zA-Z][a-zA-Z0-9_-]*. |
@OnEvent("type") |
method | Handles events of that type. The argument must be a string literal. The method receives (event, ctx). |
@OnStart |
method | Runs once when the Job starts, before the first event. |
@OnClose |
method | Runs when the Job ends. Receives (ctx, reason), where reason is "revoked", "idle_timeout", "closed_by_agent", or "failed". |
Handlers must not throw on bad input, so check the payload first, as in the example. The same event can be delivered more than once, so write cubby rows idempotently. See Event streams.
Several engagements
Declare each engagement as its own class and list them in cef.config.ts (engagements: [{ id, entry }, …]). Put these decorators on a class to control when the platform picks it:
| Decorator | Effect |
|---|---|
@Condition("cel expr") |
A CEL selection expression. You can apply it more than once; every condition must hold. |
@Priority(n) |
A lower value wins. |
@Weight(n) |
Splits traffic by probability among engagements with the same priority. |
@Limit(n, per) |
Caps how often the engagement runs. per is "day", "connection/day", or "connection/month". |
@Params({ … }) |
Param values for this engagement. |
Only one engagement may declare @OnStart, and only one may declare @OnClose. cef build fails if two do.
3. Use ctx
ctx gives you the platform. Everything else is a global in the sandbox: log with console.*, call HTTP with fetch, and use globalThis.crypto for WebCrypto.
| Member | What it does |
|---|---|
ctx.vault.publish(type, payload, opts?) |
Publishes an event into the scope, in the same stream as the event that triggered it. opts takes target (<asPubkey>:<alias>, the one agent the event is for), title, description, and correlation. |
ctx.vault.objects |
upload, get, head, presignedUrl, and list on the vault’s object storage. There is no delete: only the vault owner can delete objects. |
ctx.cubby(alias) |
A SQLite store with query(sql, params) and exec(sql, params). The cubby belongs to the Agent Service, so all of the service’s agents share it. See Cubby schema and migrations. |
ctx.memory |
The vault’s Memory Bank: upsert, update, setPrivacy, delete, relation, search, get, neighbours, and countByType. Every write needs a privacy value: "public", "internal", "private", or "restricted". |
ctx.models[alias] |
The models you declared. Call them with infer(input). See Models. |
ctx.params |
The effective params for this Job (read-only). |
ctx.settings |
The values the vault owner entered when they connected the agent (read-only). |
ctx.self |
agentId, vaultId, scope, context, jobId, and taskId. To address a sibling agent, take your own agentId and swap in the sibling’s alias. |
ctx.close(reason?) |
Ends the Job. @OnClose then receives "closed_by_agent". |
4. Declare the agent in cef.config.ts
import { defineAgent } from "@cef-ai/agent-sdk/config";
export default defineAgent({ id: "my-agent", version: "0.1.0", entry: "./src/agent.ts", idleTimeout: "30m", card: { name: "My agent", description: "Replies to messages and remembers them." }, cubbies: [{ alias: "history", migrations: "./migrations/history" }], params: { temperature: { type: "number", default: 0.3, min: 0, max: 1 }, }, settings: [ { key: "apiKey", type: "secret", required: true, label: "API key" }, ], eventSchemas: { user_message: { type: "object", properties: { text: { type: "string" } }, required: ["text"] }, },});| Field | Meaning |
|---|---|
id |
The alias. The full agent id is <agentServicePubkey>:<alias>. |
version |
Semver for this build. |
agentServicePubkey |
Optional. The Agent Service pubkey as shown in ROC. When you set it, cef build writes the full identity into the manifest. --as-pubkey on cef push overrides it. |
entry / engagements |
Either one entry file, or a list of { id, entry, goal?, condition?, priority?, weight?, limit?, params?, enabled? }. Set exactly one of the two. |
card |
name and description, plus optional iconUrl and capabilities. ROC shows the card. |
cubbies |
{ alias, migrations? }. migrations is a directory of numbered *.sql files. |
models |
A map from alias to a DDC model.json URL. Run cef typegen after changing it. See Models. |
params |
A map from name to { type, default, min?, max?, enum? }. type is "number", "string", "boolean", or "modelAlias". |
settings |
{ key, type, required?, label?, description?, default? }. type is "string", "number", "boolean", "url", or "secret". |
schedules |
Recurring triggers. See Schedules. |
widgets |
UIs shipped with the agent. See Build a widget. |
requiredScopes |
The vault scopes the agent may be connected into. Defaults to ["default"]. A connection that names any other scope is refused with MANIFEST_INVALID. "*" allows any scope. |
idleTimeout |
A duration such as "500ms", "30s", "15m", "1h", or "2d". The Job ends after this long with no activity. Defaults to "30m"; "0s" turns it off. |
eventSchemas |
JSON Schemas for the events the agent owns. |
The cubby’s schema lives in its migrations directory:
-- migrations/history/001-init.sqlCREATE TABLE messages ( id TEXT PRIMARY KEY, text TEXT NOT NULL, ts INTEGER NOT NULL);Params and settings. Params are your own settings for tuning the agent. For each Job, a param starts at its manifest default; the deployment can override it, and the engagement can override that. If the result is outside the declared min, max, or enum, the param falls back to its default. Settings are values the vault owner enters when they connect the agent.
type: "secret" only labels the form field. Nothing encrypts or hides the value.
Schedules
A schedule makes the vault publish an event to the agent on a cron schedule. Each connected vault runs its own copy, and the vault owner can pause it or change its timing.
schedules: [ { id: "daily-digest", cron: "0 8 * * 1-5", timezone: "Europe/Berlin", eventType: "digest.run", payload: { window: "24h" } },],| Field | Rule |
|---|---|
id |
Keep it stable across versions. Renaming it replaces the schedule. |
cron |
Five fields: minute, hour, day of month, month, day of week. |
timezone |
An IANA timezone name. Defaults to UTC. |
eventType |
Must be an event the agent handles. Otherwise cef build refuses the config. |
payload |
Merged into the payload of every event the schedule publishes, alongside the schedule block the vault adds. |
Schedules fire at 1-minute granularity at most, and a fire more than 4 minutes late is skipped, never backfilled. See Schedule limits.
5. Test
import path from "node:path";import { testAgent } from "@cef-ai/testing";import HelloAgent from "../src/agent.js";
const h = testAgent(HelloAgent, { cubbies: [{ alias: "history", migrations: path.resolve("migrations/history") }],});const events = await h.dispatch({ type: "user_message", payload: { text: "hi" } });// events contains { type: "reply", … }const rows = await h.cubby("history").query("SELECT text FROM messages");h.dispose();See the testing reference.
6. Build, push, deploy
cef build # dist/<alias>/bundle.js + manifest.jsoncef push --bucket <bucketId> --as-pubkey <agentServicePubkey>cef deploy --as-pubkey <agentServicePubkey> # applies deployments/A deployed agent still does nothing until a vault owner connects it. See Push and deploy and Connect to a vault.
What cef build rejects
- An
@OnEventargument that is not a string literal. ctx.models.<alias>for an alias that is not inmodels.- Imports of Node built-ins (
fs,path,net,http,child_process,crypto,buffer,stream, and others) and ofpg,mysql,mysql2,mongodb,redis,ioredis,sqlite3,better-sqlite3,ws,node-fetch, andaxios. @OnEvent("__start__")or@OnEvent("__close__"). Use@OnStartand@OnCloseinstead.- A schedule whose
eventTypethe agent does not handle.
A ctx.cubby alias that is not in cubbies only produces a warning, because the Agent Service may declare that cubby. The eslint plugin shows the same checks in your editor.