Skip to content

Triggers & the Envelope ​

A trigger describes how a run of an agent can start. At intake every trigger becomes the same shape, a TriggerEnvelope, and engine.dispatch() turns the envelope into a queued run.

Trigger definitions ​

ts
const triage = defineAgent({
    name: 'triage',
    model,
    instructions: 'Triage the issue in the trigger payload.',
    triggers: [
        http(), // id 'http'
        webhook({ path: '/issues' }), // id 'webhook:/issues'
        cron('*/30 * * * *', { overlap: 'skip' }), // id 'cron:*/30 * * * *'
    ],
});
BuilderSourceIdOptions
http(opts?)httphttpschema?: a Standard Schema for the payload. At most one per agent; REST, the UI and MCP start runs through it
webhook(opts)webhookwebhook:<path>path (starts with /), schema?, idempotencyKey?, concurrencyKey?. Many per agent
cron(expression, opts?)croncron:<expression>timezone?, overlap? (skip by default, queue-one, allow), payload?
githubWebhook(opts) from @chamvi/githubgithubgithub:<events>events ('issues' or 'issues.opened'), id?, filter?, idempotencyKey? (default the delivery id), concurrencyKey? (default <owner>/<repo>#<number>). See GitHub App

cron() validates the expression (and timezone) with cron-parser and throws InvalidAgentDefinitionError for an invalid one; webhook() throws it for a path that does not start with /. Trigger ids must be unique per agent, so an agent declares http() at most once.

A TriggerDefinition may also carry concurrencyKey(payload) and idempotencyKey(payload) functions (webhook() copies them from its options). They are meant for the intake packages, which compute the envelope keys from a payload.

Firing triggers is not in core

In @chamvi/core, engine.dispatch() is the only way a run starts. The trigger definitions describe intent; nothing in core fires a cron expression or listens on an HTTP path. @chamvi/scheduler fires cron() triggers (with per-tick locks and the overlap policy); @chamvi/http serves http() triggers over REST and MCP and webhook() triggers at /hooks/:agent<path>; @chamvi/github serves githubWebhook() triggers at /webhooks/github.

The envelope ​

ts
/*
 * Copyright (c) 2026.
 * Author Peter Placzek (tada5hi)
 * For the full copyright and license information,
 * view the LICENSE file that was distributed with this source code.
 */

import type { Artifact } from '../artifact/types.ts';

export type TriggerSource = 'cron' | 'http' | 'webhook' | 'github' | 'mcp';

export type TriggerDefinition<P = unknown> = {
    source: TriggerSource;
    /** Unique within an agent, e.g. 'cron:*\/30 * * * *'. */
    id: string;
    /** Source-specific (cron: { expression, timezone?, overlap }). */
    options: Record<string, unknown>;
    // method signatures (bivariant): a TriggerDefinition<P> fits AgentDefinition.triggers
    concurrencyKey?(payload: P): string | undefined;
    idempotencyKey?(payload: P): string | undefined;
};

/**
 * Who started a run (or answered an approval), as an authenticator resolved
 * it: a snapshot of identity and grants taken at dispatch, never a token.
 * Revoking a grant later does not reach a run that already holds it.
 */
export type Invoker = {
    id: string;
    /**
     * The authenticator's label: `@chamvi/http`'s staticTokens() sets
     * 'token' and authup() sets 'authup'; oidc() keeps the kind its
     * `principal` mapper returns (the docs use 'oidc'); a custom authenticator
     * or a direct dispatch may use its own (e.g. 'user').
     */
    kind: string;
    name?: string;
    permissions: readonly string[];
};

export type TriggerEnvelope<P = unknown> = {
    id: string;
    agent: string;
    trigger: string;
    source: TriggerSource;
    payload: P;
    idempotencyKey?: string;
    concurrencyKey?: string;
    thread?: string;
    /** Files the run was started with, stored as artifacts of the run; the model gets them as file parts. */
    attachments?: Artifact[];
    /**
     * Who started the run; only ever set from an authenticator (never from a
     * payload). Absent for cron and webhook runs. With an invoker the
     * idempotency key is scoped to it, so only the same invoker's dispatch
     * is deduplicated (and gets its own first run).
     */
    invoker?: Invoker;
    receivedAt: Date;
};

dispatch() takes the envelope without id and receivedAt; it fills in a random UUID and the current time. trigger is the trigger id ('http', 'webhook:/issues', ...). source is one of cron, http, webhook, github or mcp (a run started through http() over MCP has trigger: 'http' and source: 'mcp').

The engine renders the source, trigger id and payload as a JSON block and makes it the run's first user message. The model sees exactly that; put what it needs into the payload.

thread is stored on the run. Loading the history of earlier runs of the same thread is planned for the mail digest milestone.

invoker is who started the run. Only an intake layer sets it, from its authenticated caller (@chamvi/http fills it from the principal), never from a payload. It is not part of the rendered block, so the model never sees it. See Restricting tools per invoker.

Attachments ​

dispatch() also takes files for the model, attachments: [{ mediaType, filename?, data }] with data a Uint8Array:

ts
const withFile = await engine.dispatch({
    agent: 'triage',
    trigger: 'http',
    source: 'http',
    payload: { repo: 'acme/app', issue: 43 },
    // stored as artifacts of the run; the model gets them as file parts
    attachments: [{
        mediaType: 'image/png',
        filename: 'crash.png',
        data: new Uint8Array(await readFile('crash.png')),
    }],
});

const run = await engine.getRun(withFile.runId);
run?.trigger.attachments; // [{ id, runId, agent, mediaType: 'image/png', filename: 'crash.png', size, createdAt }]

Each attachment is checked before anything is written (mediaType of the form type/subtype without parameters, data a Uint8Array; ValidationError otherwise), then stored as an artifact of the run, after the idempotency check (a deduplicated dispatch stores nothing) and before the run is created. The run's trigger.attachments holds their metadata. The first user message then carries the rendered envelope as text followed by one file part per attachment, so a model that reads images or PDFs gets them directly. Attachment ids are derived from the run id and the position (<runId>.0000, <runId>.0001, ...), so the run's artifacts list in the order they were given. A crash between storing the files and creating the run leaves orphan artifacts; nothing purges them yet. Two dispatches that race to create the run of the same reserved idempotency key (its first run was never created) write to the same ids, so the second write of each file is a no-op and the run names exactly the files that were stored; only a racing dispatch with more files than the winner leaves its extra files listed among the run's artifacts.

Over HTTP, POST /agents/:name/runs takes the same list with data as base64 (see HTTP API).

Idempotency keys ​

ts
const first = await engine.dispatch({
    agent: 'triage',
    trigger: 'webhook:/issues',
    source: 'webhook',
    payload: { repo: 'acme/app', issue: 42 },
    // a redelivery with the same key returns the same run
    idempotencyKey: 'delivery-7f3a',
    // one run at a time per issue
    concurrencyKey: 'acme/app#42',
});

const again = await engine.dispatch({
    agent: 'triage',
    trigger: 'webhook:/issues',
    source: 'webhook',
    payload: { repo: 'acme/app', issue: 42 },
    idempotencyKey: 'delivery-7f3a',
});

again.runId === first.runId; // true
again.deduplicated; // true

With an idempotencyKey, dispatch() claims the key in the store under the scope <agent>:<source> (for example triage:webhook) for 7 days. The first dispatch creates the run. Every later dispatch with the same agent, source and key returns the existing run id with deduplicated: true, and no new run is created.

A dispatch with an invoker claims the key under <agent>:<source>:<kind>:<id> instead (kind and id URI-encoded). Keys are then per invoker: the same invoker's retry is deduplicated, while another invoker sending the same key gets a run of its own and never learns about the first.

Use a key that is stable across redeliveries: a GitHub delivery id, a cron job plus its scheduled time, or a key your client sends.

If the store claimed the key but the run was never created (for example createRun failed right after the claim), the next dispatch with that key creates the run under the reserved id. createRun is insert-if-absent, so concurrent dispatches create it once.

Concurrency keys ​

A run with a concurrencyKey starts only when no other run of the same agent with the same key is running, waiting or interrupted. Runs with different keys, and runs without a key, run in parallel up to the engine's maxConcurrentRuns. See Concurrency, Leases & Multi-Replica.

Typical keys: one per repository, issue or pull request (acme/app#42), or one per mailbox.