AI Agents
Agents are autonomous AI actors that respond to events, execute tools, and produce side effects. They don't return values — they research, notify, update records, and transition state. The app owns its data and state machine; the platform provides the AI loop, event bus, and observability.
How Agents Work
External systems → webhooks → emit() → agents
User actions → server routes → emit() → agents
Agent tools → emit() → agents
Cron → platform → agents (no context)
- Something happens — a user submits a form, an email arrives, a cron fires
- A server route or webhook handler calls
emit('event_name', payload) - The platform matches the event to agents that declared
on: event_name - Each matching agent runs: the AI model reads the payload, calls tools, and stops when done
- Tools can emit more events, chaining agents together
Agents have no memory between runs. They don't need one — all their previous work is in the database. Each run starts fresh and reads the current state of the world.
Project Structure
An agent-enabled app adds three directories to the standard app structure:
my-app/
src/ # Client-side UI
server/ # Server-side route handlers
tickets/
index.js # GET list, POST create → emit('ticket_created')
[id].js # GET detail
[id]/
reply.js # POST reply → emit('ticket_replied')
webhooks/ # Public webhook endpoints (no auth)
gmail/
push.js # Gmail Pub/Sub → emit('ticket_reply_received')
tools/ # Agent tool definitions
get_ticket.js
classify_ticket.js
update_ticket.js
send_email.js
migrations/ # Postgres workspace tables
001-create-tickets.sql
informer.yaml # Events, agents, access rules
package.json
| Directory | Purpose |
|---|---|
tools/ | Shared tool implementations — agents call these |
server/ | HTTP routes that receive user actions and emit events |
webhooks/ | Public HTTP endpoints for external systems (JWT-authenticated) |
Declaring Agents
Agents are declared in informer.yaml alongside events and access rules:
# informer.yaml
access:
integrations:
- admin:helpdesk-gmail
apis:
- POST /api/models/go_everyday/_object
events:
ticket_created:
description: New support ticket submitted
ticket_triaged:
description: Triage agent classified and updated a ticket
ticket_reply_received:
description: Inbound email reply matched to a ticket
agents:
triage:
description: Classifies tickets by priority and category, detects duplicates
instructions: |
You are an IT support triage agent. When a ticket is created:
1. Read it using get_ticket.
2. ALWAYS call get_recent_tickets to check for duplicates.
3. If duplicate found, call flag_duplicate. Then STOP.
4. If not duplicate, classify by priority and category using classify_ticket.
5. Use update_ticket to set priority, category, and status to 'triaged'.
model: go_everyday
tools:
- get_ticket
- get_recent_tickets
- flag_duplicate
- classify_ticket
- update_ticket
on:
- ticket_created
auto-responder:
description: Drafts and sends an initial response for triaged tickets
instructions: |
You are an IT support auto-responder. When a ticket has been triaged:
1. Read it using get_ticket.
2. Research the issue using the GitHub toolkit.
3. Draft a response, then send it via send_email.
model: go_everyday
tools:
- get_ticket
- draft_response
- send_email
toolkits:
- admin:github
on:
- ticket_triaged
Agent Properties
| Property | Type | Description |
|---|---|---|
description | string | What the agent does (shown in admin UI) |
instructions | string | System prompt — the agent's playbook. Admin-overridable from UI. |
model | string | Model slug: go_everyday, go_advanced, go_strategic. Default: go_everyday. |
tools | string[] | Tool names from the tools/ directory |
toolkits | string[] | Platform toolkit IDs — their tools are added to the agent's tool set |
assistants | string[] | Platform assistant IDs — their instructions merge into the agent's prompt |
on | string | string[] | Event name(s) that trigger this agent |
cron | string | Cron schedule (5-field: min hour dom mon dow) |
webSearch | boolean | Enable web search tool |
onFailure | string | Event to emit when a run of this agent terminally fails — lets you model an error branch (see Handling failures) |
Handling failures
A successful run advances the workflow by emit()-ing the next event from a tool. If a run terminally fails, nothing is emitted and the workflow stops — the failure is visible in the app's logs, but there is no forward transition.
Declare onFailure to give an agent an explicit error branch: when a run of that agent terminally fails, the platform emits the named event so another agent can react — compensate, notify, mark the work item failed, and so on.
agents:
research-topic:
instructions: Research the requested topic and save findings.
tools: [web_search, save_findings]
on: research_requested
onFailure: research_failed # emitted if a run terminally fails
handle-research-failure:
instructions: Mark the workflow item failed and notify the owner.
tools: [mark_failed, notify_owner]
on: research_failed # your error branch
The onFailure event fires with a standard envelope describing the failure:
{
"error": "<failure message>",
"runId": "<failed run id>",
"agent": "research-topic",
"event": "research_requested",
"payload": { "...": "the payload that triggered the failed run" }
}
Semantics:
- Fires once — emitted when a run terminally fails; never re-emitted if the source event is later retried.
- Branches instead of retrying — declaring
onFailurehands a failed run to your error branch and marks the source event handled, instead of the default blind retry. An event whose matching agents declare noonFailurekeeps the default behavior: retry up to 5 times, then dead-letter. - Shared events — when several agents trigger on the same event, each agent's failure is independent: any failed agent that declares
onFailureemits its failure event. The event only falls back to retry/dead-letter when every matching agent fails and none declaresonFailure. - Loop guard — failure events carry a reserved
_failureDepthcounter (the platform adds it to the payload — don't set or rename it). If an error handler itself fails and also declaresonFailure, the chain is capped after 3 hops so a broken handler can't spawn a runaway failure-event chain.
Writing Tools
Each file in tools/ exports a single tool: a description, a JSON schema, and a handler function.
// tools/get_ticket.js
export const description = 'Read a support ticket by ID';
export const schema = {
type: 'object',
properties: {
ticket_id: { type: 'number', description: 'The ticket ID to retrieve' }
},
required: ['ticket_id']
};
export async function handler({ args, query }) {
const { ticket_id } = args;
const [ticket] = await query('SELECT * FROM tickets WHERE id = $1', [ticket_id]);
if (!ticket) return { error: `Ticket #${ticket_id} not found` };
return ticket;
}
The Handler Bag
A tool handler receives a single context bag — nearly the same service surface that server routes and webhooks receive. The differences: a tool gets args (the AI input) and run (run metadata) in place of request, and it has no respond() (tools aren't HTTP request handlers):
export async function handler({ args, run, context, query, fetch, emit, notify, email, crypto, markdown, log, env }) {
// ...
}
| Member | Type | Description |
|---|---|---|
args | object | The AI-supplied tool input, validated against the tool's schema |
run | object | Agent-run metadata: { appId, agentId, runId, trigger } (the triggering event is run.trigger) |
context | object | Typed bound dependencies — context.<slot>.<method>(...) |
query | async (sql, params?) => rows | Execute SQL against the app's Postgres workspace |
fetch | async (path, opts?) => { status, body } | Authenticated API call through Informer (respects informer.yaml access rules) |
emit | (event, payload) => void | Emit an event — can trigger other agents |
notify | async (username, message) => { id } | Enqueue a push notification for delivery to a user's devices |
email | async (to, message) => { id } | Enqueue an email for delivery via the tenant's mail transport |
crypto | object | hmac, hash, randomUUID, randomBytes, timingSafeEqual, verifyHmac, encrypt/decrypt, verify |
markdown | async (text) => html | Convert markdown to HTML |
log | function | Structured logging (log, log.info, log.warn, log.error, log.debug) |
env | object | App environment variables |
Tools run in sandboxed V8 isolates — no Node.js APIs, no filesystem, no network except through fetch().
Migration note: earlier tools used
(args, services)with two arguments and read the triggering payload fromcontext. Tools now take a single bag: the AI input isargs, the triggering event moved torun.trigger, andcontextis the typed dependency proxy. Tools also gainedcrypto,env,notify, and
Tool That Emits Events
Tools can emit events to chain agents together. This is how one agent's action triggers the next:
// tools/update_ticket.js
export const description = 'Update ticket fields. Setting status to "triaged" emits ticket_triaged.';
export const schema = {
type: 'object',
properties: {
ticket_id: { type: 'number', description: 'The ticket ID to update' },
status: { type: 'string' },
priority: { type: 'string', enum: ['low', 'medium', 'high', 'critical'] },
category: { type: 'string', enum: ['hardware', 'software', 'network', 'access', 'other'] }
},
required: ['ticket_id']
};
export async function handler({ args, query, emit }) {
const { ticket_id, status, priority, category } = args;
const sets = [];
const vals = [];
let idx = 1;
if (status) { sets.push(`status = $${idx++}`); vals.push(status); }
if (priority) { sets.push(`priority = $${idx++}`); vals.push(priority); }
if (category) { sets.push(`category = $${idx++}`); vals.push(category); }
sets.push('updated_at = NOW()');
if (sets.length === 1) return { error: 'No fields to update' };
vals.push(ticket_id);
const [updated] = await query(
`UPDATE tickets SET ${sets.join(', ')} WHERE id = $${idx} RETURNING *`,
vals
);
await query(
`INSERT INTO ticket_events (ticket_id, event_type, details) VALUES ($1, 'updated', $2)`,
[ticket_id, JSON.stringify({ status, priority, category })]
);
// Chain: triage → auto-responder
if (status === 'triaged') {
emit('ticket_triaged', { ticket_id });
}
return { success: true, ticket: updated };
}
Tool That Calls Integrations
Tools can call external APIs through Informer integrations via fetch():
Since the sandbox unification, agent tools receive the full handler bag — including the typed context proxy. So a tool can reach a dependency two ways:
- Dependency slot (preferred): declare the integration as a
dependencies:slot and callcontext.<slot>.request({ ... }). No resource IDs in code, and it survives rebinds and bundle export/import. - Raw
fetch+access:(shown below): callfetch('integrations/<id>/request')and grant the path in anaccess: integrations:block (as theinformer.yamlabove does). Handy when you don't want a typed slot.
// tools/send_email.js
export const description = 'Send an email to a ticket submitter via Gmail';
export const schema = {
type: 'object',
properties: {
ticket_id: { type: 'number', description: 'Ticket ID for reply tracking' },
to: { type: 'string', description: 'Recipient email' },
subject: { type: 'string' },
body: { type: 'string', description: 'Email body (markdown supported)' }
},
required: ['ticket_id', 'to', 'subject', 'body']
};
export async function handler({ args, fetch, query, markdown }) {
const { ticket_id, to, subject, body } = args;
// Get sender email from Gmail profile
const profileResp = await fetch('integrations/admin:helpdesk-gmail/request', {
method: 'POST',
body: { url: '/users/me/profile', method: 'GET' }
});
const senderEmail = profileResp.body?.emailAddress;
const [localPart, domain] = senderEmail.split('@');
// Plus-address so replies route back to this ticket
const replyTo = `${localPart}+ticket-${ticket_id}@${domain}`;
const taggedSubject = `${subject} [#${ticket_id}]`;
// Convert markdown body to HTML
const htmlBody = await markdown(body);
const raw = buildRfc2822({ to, replyTo, subject: taggedSubject, html: htmlBody });
// Send via Gmail API
const resp = await fetch('integrations/admin:helpdesk-gmail/request', {
method: 'POST',
body: { url: '/users/me/messages/send', method: 'POST', body: { raw } }
});
// Track thread for reply matching
await query(
`INSERT INTO email_threads (ticket_id, gmail_thread_id, gmail_message_id, direction, subject)
VALUES ($1, $2, $3, 'outbound', $4)`,
[ticket_id, resp.body.threadId, resp.body.id, taggedSubject]
);
return { success: true, thread_id: resp.body.threadId };
}
Emitting Events
Server routes and webhook handlers emit events via emit(). The route returns immediately — event processing is asynchronous.
From Server Routes
// server/tickets/index.js
export async function POST({ query, request, emit }) {
const { subject, description, submitted_by } = request.body;
const [ticket] = await query(
`INSERT INTO tickets (subject, description, submitted_by) VALUES ($1, $2, $3) RETURNING *`,
[subject, description, submitted_by]
);
await query(
`INSERT INTO ticket_events (ticket_id, event_type, details) VALUES ($1, 'created', $2)`,
[ticket.id, JSON.stringify({ submitted_by })]
);
emit('ticket_created', { ticket_id: ticket.id });
return ticket;
}
From Webhook Handlers
Webhooks receive requests from external systems and translate them into events:
// webhooks/gmail/push.js
export async function POST({ query, fetch, emit, request }) {
// Gmail Pub/Sub delivers: { message: { data: base64({emailAddress, historyId}) } }
const pubsubMessage = request.body?.message;
if (!pubsubMessage?.data) {
return { status: 200, body: { skipped: true } };
}
const notification = JSON.parse(await base64UrlDecode(pubsubMessage.data));
// Fetch new messages from Gmail history API
const historyResp = await fetch('integrations/admin:helpdesk-gmail/request', {
method: 'POST',
body: {
url: '/users/me/history',
method: 'GET',
params: { startHistoryId: cursor, historyTypes: 'messageAdded', labelId: 'INBOX' }
}
});
// For each new inbound message, match to a ticket and emit
for (const messageId of newMessageIds) {
const ticketId = extractTicketId(message); // from plus-address or subject [#N]
if (!ticketId) {
// Cold inbound email — create a new ticket
const [newTicket] = await query(
'INSERT INTO tickets (subject, description, status, submitted_by) VALUES ($1, $2, $3, $4) RETURNING id',
[subject, bodyText, 'open', fromEmail]
);
emit('ticket_created', { ticket_id: newTicket.id });
} else {
// Reply to existing ticket
await query(
'INSERT INTO ticket_responses (ticket_id, content, response_type) VALUES ($1, $2, $3)',
[ticketId, bodyText, 'email_reply']
);
emit('ticket_reply_received', { ticket_id: ticketId, from: fromEmail, body: bodyText });
}
}
return { status: 200, body: { replies_found: results.length } };
}
Webhook files in webhooks/ are public endpoints — they don't require user authentication. Instead, each app has a per-app webhook secret used for JWT-based token verification. External services include ?token=<jwt> in the URL.
Agent Chaining
The real power of agents emerges when they chain through events. Here's the IT helpdesk pipeline:
User submits ticket (or email arrives)
→ server route / webhook emits 'ticket_created'
→ [triage agent]
→ get_ticket → get_recent_tickets
→ [if duplicate] flag_duplicate → STOP
→ [if not] classify_ticket → update_ticket(status='triaged')
→ tool emits 'ticket_triaged'
→ [auto-responder agent]
→ get_ticket → research via GitHub toolkit → draft_response → send_email
→ [escalation agent]
→ get_ticket → [if critical] escalate_ticket
Customer replies to email
→ Gmail Pub/Sub → webhook emits 'ticket_reply_received'
→ [reply-handler agent]
→ get_ticket
→ [if resolved] update_ticket(status='resolved') → STOP
→ [if ongoing] research → draft_response → send_email
Each agent is small and focused. The triage agent doesn't know how to send emails. The auto-responder doesn't know how to classify tickets. They communicate through events and shared database state.
Cron-Scheduled Agents
Agents can run on a schedule instead of (or in addition to) events. A cron agent wakes up with no context and finds its own work through tools:
agents:
follow-up:
description: Nudges reporters who haven't responded
instructions: |
Find tickets in 'needs-info' state older than 48 hours.
Send a polite follow-up. If it's been over a week, close as stale.
model: go_everyday
tools:
- find_tickets
- update_ticket
- send_email
cron: '0 9 * * *' # 9:00 AM daily
Standard 5-field cron format: minute hour day-of-month month day-of-week
| Expression | Schedule |
|---|---|
0 9 * * * | Daily at 9:00 AM |
0 9 * * 1 | Every Monday at 9:00 AM |
*/15 * * * * | Every 15 minutes |
0 17 * * 1-5 | Weekdays at 5:00 PM |
0 0 1 * * | First of every month at midnight |
Toolkits and Assistants
Agents can extend their capabilities by referencing platform-level toolkits and assistants:
agents:
auto-responder:
instructions: Research issues and draft responses.
tools:
- get_ticket
- draft_response
- send_email
toolkits:
- admin:github # Adds search_code, get_file_contents, etc.
assistants:
- admin:helpdesk # Merges assistant's system prompt into agent's
on:
- ticket_triaged
Toolkits provide additional tools from platform-registered MCP servers or custom toolkits. At runtime, the agent receives its app tools plus all toolkit tools. Toolkit system instructions are appended to the agent's prompt.
Assistants contribute their system prompt to the agent. This lets you share a persona or knowledge base across multiple agents without duplicating instructions.
References are validated at deploy time — if a toolkit or assistant doesn't exist, deploy fails with a clear error.
Database Schema
Agent-enabled apps use the app's Postgres workspace for their domain tables. Migrations are plain SQL:
-- migrations/001-create-tickets.sql
CREATE TABLE tickets (
id SERIAL PRIMARY KEY,
subject TEXT NOT NULL,
description TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'open',
priority TEXT,
category TEXT,
submitted_by TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW(),
updated_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE ticket_responses (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tickets(id),
content TEXT NOT NULL,
response_type TEXT NOT NULL DEFAULT 'auto',
created_at TIMESTAMPTZ DEFAULT NOW()
);
CREATE TABLE ticket_events (
id SERIAL PRIMARY KEY,
ticket_id INTEGER NOT NULL REFERENCES tickets(id),
event_type TEXT NOT NULL,
details JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
The ticket_events table is a key pattern — it provides an audit trail of everything agents do. The UI can render this as a timeline showing classifications, escalations, auto-responses, and status changes.
Admin Controls
Instruction Overrides
Admins can edit an agent's instructions from the Informer UI without redeploying. The YAML provides the default; the override takes precedence. A "Reset to default" button restores the YAML version.
Agent States
| State | Cron | Events | Description |
|---|---|---|---|
| Active | Fires | Delivered | Normal operation |
| Paused | Suspended | Queued | Events queue up, nothing lost |
| Stopped | Off | Rejected | Agent is dormant |
Run History
Every agent invocation is logged with the full tool-call trace:
Run #1847 — triage — 2026-02-28 14:32:01 — 4.2s — 1,847 tokens
Trigger: ticket_created
Context: { ticket_id: 42 }
Step 1: get_ticket({ ticket_id: 42 })
→ { id: 42, subject: "Connection timeout on Android", ... }
Step 2: get_recent_tickets({ submitted_by: "user@example.com", exclude_id: 42 })
→ [{ id: 31, subject: "WiFi drops on Pixel" }]
Step 3: classify_ticket({ ticket_id: 42, priority: "high", category: "network", ... })
Step 4: update_ticket({ ticket_id: 42, status: "triaged", priority: "high", category: "network" })
Result: completed (4 steps, 0 errors)
Design Principles
Apps own state. The platform has no opinion about states, transitions, or workflows. Agents read and write app tables through tools — the "workflow" is emergent from instructions and data.
Agents work by side effect. An agent doesn't return a decision for the caller to apply. It acts: queries data, sends notifications, updates records. When it's done, the world has changed.
Events decouple everything. Routes don't call agents directly. They emit events and return. The platform delivers events to agents asynchronously.
No memory needed. Each agent run is stateless. Previous runs left their work in the database — classifications, responses, status changes. The next run reads current state and acts on it.
Complete Example: IT Helpdesk
The IT helpdesk app is a fully automated support system with four agents:
| Agent | Triggers On | What It Does |
|---|---|---|
triage | ticket_created | Checks for duplicates, classifies by priority/category |
auto-responder | ticket_triaged | Researches the issue, drafts and sends a response email |
escalation | ticket_triaged | Escalates critical tickets to senior staff |
reply-handler | ticket_reply_received | Processes customer replies, closes resolved tickets |
The system handles tickets from two sources:
- Web form — user submits through the app UI → server route emits
ticket_created - Inbound email — Gmail Pub/Sub → webhook creates ticket or matches reply → emits
ticket_createdorticket_reply_received
Email threading uses plus-addressing (helpdesk+ticket-42@gmail.com) so replies automatically route back to the correct ticket.
The full informer.yaml and all tool implementations are available in the it-helpdesk example.