Skip to content

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

Terminal window
npx @cef-ai/cli init my-agent
cd my-agent
npm install @cef-ai/agent-sdk@5.8.0
npm install -D @cef-ai/cli@2.8.0 @cef-ai/testing@3.3.5

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

src/agent.ts
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.sql
CREATE 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

Terminal window
cef build # dist/<alias>/bundle.js + manifest.json
cef 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 @OnEvent argument that is not a string literal.
  • ctx.models.<alias> for an alias that is not in models.
  • Imports of Node built-ins (fs, path, net, http, child_process, crypto, buffer, stream, and others) and of pg, mysql, mysql2, mongodb, redis, ioredis, sqlite3, better-sqlite3, ws, node-fetch, and axios.
  • @OnEvent("__start__") or @OnEvent("__close__"). Use @OnStart and @OnClose instead.
  • A schedule whose eventType the 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.