Skip to main content

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)
  1. Something happens — a user submits a form, an email arrives, a cron fires
  2. A server route or webhook handler calls emit('event_name', payload)
  3. The platform matches the event to agents that declared on: event_name
  4. Each matching agent runs: the AI model reads the payload, calls tools, and stops when done
  5. 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
DirectoryPurpose
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

PropertyTypeDescription
descriptionstringWhat the agent does (shown in admin UI)
instructionsstringSystem prompt — the agent's playbook. Admin-overridable from UI.
modelstringModel slug: go_everyday, go_advanced, go_strategic. Default: go_everyday.
toolsstring[]Tool names from the tools/ directory
toolkitsstring[]Platform toolkit IDs — their tools are added to the agent's tool set
assistantsstring[]Platform assistant IDs — their instructions merge into the agent's prompt
onstring | string[]Event name(s) that trigger this agent
cronstringCron schedule (5-field: min hour dom mon dow)
webSearchbooleanEnable 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):

ServiceTypeDescription
queryasync (sql, params?) => rowsExecute SQL against the app's Postgres workspace
fetchasync (path, opts?) => { status, body }Authenticated API call through Informer (respects informer.yaml access rules)
emit(event, payload) => voidEmit an event — can trigger other agents
notifyasync (username, message) => { id }Enqueue a push notification for delivery to a user's devices
emailasync (to, message) => { id }Enqueue an email for delivery via the tenant's mail transport
markdownasync (text) => htmlConvert markdown to HTML
contextobjectThe 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

ExpressionSchedule
0 9 * * *Daily at 9:00 AM
0 9 * * 1Every Monday at 9:00 AM
*/15 * * * *Every 15 minutes
0 17 * * 1-5Weekdays 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

StateCronEventsDescription
ActiveFiresDeliveredNormal operation
PausedSuspendedQueuedEvents queue up, nothing lost
StoppedOffRejectedAgent 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:

AgentTriggers OnWhat It Does
triageticket_createdChecks for duplicates, classifies by priority/category
auto-responderticket_triagedResearches the issue, drafts and sends a response email
escalationticket_triagedEscalates critical tickets to senior staff
reply-handlerticket_reply_receivedProcesses 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_created or ticket_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.