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 |
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({ ticket_id }, { query }) {
const [ticket] = await query('SELECT * FROM tickets WHERE id = $1', [ticket_id]);
if (!ticket) return { error: `Ticket #${ticket_id} not found` };
return ticket;
}
Handler Services
Every tool handler receives (args, services):
| Service | Type | Description |
|---|---|---|
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 |
markdown | async (text) => html | Convert markdown to HTML |
context | object | The event payload that triggered the agent run |
Tools run in sandboxed V8 isolates — no Node.js APIs, no filesystem, no network except through fetch().
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({ ticket_id, status, priority, category }, { query, emit }) {
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():
// 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({ ticket_id, to, subject, body }, { fetch, query, markdown }) {
// 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.