Intents
Intents are deterministic TypeScript actions that run instead of the LLM. Where a skill hands a task to the model and a tool gives the model one capability, an intent is code you wrote end to end: it composes tool calls and skill calls imperatively, and the runtime executes it without a model round trip. Use an intent when the logic is fully specifiable (seeding data, pro-rata math, a wizard with fixed steps) and reserve the LLM for the judgment calls inside it (via ctx.callSkill).
Every intent lives in its own directory and default-exports its definition:
amodal/
├── _lib/ ← shared helpers, imported relatively from intents
├── _types/ ← vendored authoring types (see "Typing your intents")
└── intents/
├── analyze-submission/
│ └── intent.ts ← default-exports the definition; id must equal the directory name
└── propose-distribution/
├── intent.ts
└── helpers.ts ← sibling .ts files are bundled inThe runtime discovers intents automatically from amodal/intents/; there is no manifest list to register them in. Each intent.ts is compiled with esbuild (relative imports inlined, npm imports left external), so an intent can be split across sibling files and share code from amodal/_lib/. Directories under intents/ whose name starts with _ or . (and any without an intent.ts) are skipped by the loader.
There are two kinds of intent, split by execution model, and the runtime tells them apart by how the definition was authored:
| Classic (regex/chat) | Replay (durable session) | |
|---|---|---|
| Authored as | plain IntentDefinition object | defineIntent({...}) |
| Fired by | a chat message matching its regex; ctx.callIntent from another intent | the UI (useIntentRun / useIntentTrigger); automations; webhooks |
| Runs | inline in the chat turn, emits into the chat stream | its own durable session, visible in the sessions list |
| Can pause | no, runs to completion in the turn | yes: operator input, timers, inbound webhooks |
| Returns | a value (and optional chat text via emitText) | nothing; results go to stores + session metadata |
A replay intent never fires from a chat message. Only classic intents with a
regexparticipate in chat matching. If you author an action withdefineIntentand expect typing its trigger phrase in chat to run it, nothing happens; the message falls through to the LLM. Conversely,POST /api/intents/:id/run(theuseIntentRunlane) runs only replay intents. If one action needs both surfaces, write a thin classic intent and a thin replay intent that call the same shared function from_lib/.
Classic intents
A classic intent is a deterministic shortcut in the chat lane. On every user message, the runtime walks the loaded intents in order and tests each regex against the literal message text. On the first match it runs that intent's handler instead of calling the LLM; the synthetic assistant message it produces is indistinguishable from a normal turn in the chat UI.
// amodal/intents/analyze-submission/intent.ts
import type { IntentDefinition } from "../../_types/intent-context.js";
const intent: IntentDefinition = {
id: "analyze-submission",
regex: /^(?:analyze|triage|review|assess)\s+(sub_[a-z0-9_]+)$/i,
async handle(ctx) {
const submissionId = ctx.match?.[1] ?? "";
const submission = await ctx.callTool("store__submissions__get", { key: submissionId });
if (!submission) return null; // fall through to the LLM
const review = await ctx.callSkill("appetite-review", {
prompt: `Review submission ${submissionId}`,
});
await ctx.callTool("store__risk_findings__set", { /* … */ });
ctx.emitText(`Triage complete for ${submissionId}.`);
return {};
},
};
export default intent;Matching rules worth knowing:
- First match wins. There is no priority field; intents are loaded alphabetically by directory name, and the first regex that matches the message runs. Anchor your patterns (
^...$) and keep phrasing precise so intents don't shadow each other or steal natural-language messages meant for the LLM. - The regex is optional. An intent without one never fires from chat but can still be invoked imperatively via
ctx.callIntent(name, input)from another intent, or targeted by an automation.
Inside handle, the context is imperative glue:
| Member | What it does |
|---|---|
ctx.match | The RegExpExecArray from the trigger message (absent for imperative calls, which use ctx.input). |
ctx.callTool(name, params) | Run a tool from the session's registry; emits the same SSE events a model-made call would, so the chat UI renders tool cards. Tools with requiresConfirmation: true are refused, as are connection tools (their ACL gate lives in the agent loop): confirmation gates are exactly where you want LLM judgment. |
ctx.callSkill(name, {prompt, context}) | Run a skill (an LLM loop) and get its text + structured result back. |
ctx.callIntent(name, input) | Invoke another loaded intent with typed input. |
ctx.emitText(text) / ctx.emitProgress(msg) | User-visible prose / progress in the chat stream. Most intents skip emitText; the tool cards speak for themselves. |
ctx.forEach(items, fn, {concurrency, onError}) | Bounded-concurrency iteration helper. |
ctx.scheduleAutomation(name, {schedule, input}) | Create a durable automation binding targeting an intent marked schedulable. |
The return value steers what happens after the deterministic part:
{}: done; the runtime appends the synthetic messages and the turn ends with no LLM call.{continue: true}: the deterministic work is done but the next step needs LLM judgment; the runtime appends the intent's messages and then runs the agent loop so the model picks up from the new state.null: abort and fall through to the LLM (the regex matched but the state isn't deterministically resolvable). Only valid before the firstctx.callTool; throwing instead fails the intent and surfaces an error to the user. Usenullfor "not my problem", throw for "it broke".
Replay intents
A replay intent is a durable, operator-watched workflow. It drives its own session (not a chat turn), can pause for operator input, a scheduled wake-up, or an inbound webhook, and survives restarts. Author it with defineIntent:
// amodal/intents/propose-distribution/intent.ts
import { defineIntent } from "../../_types/replay-intent.js";
export interface ProposeDistributionInput {
fund_id: string;
amount_usd: number;
}
export default defineIntent<ProposeDistributionInput>({
id: "propose-distribution",
surface: {
category: "distributions",
titleTemplate: "Distribute {amount_usd} from {fund_id}",
steps: [
{ id: "compute", label: "Compute pro-rata allocations" },
{ id: "approve", label: "Operator approval" },
{ id: "record", label: "Write distribution rows" },
],
permissionsSummary: "Reads investors/funds; writes distributions.",
},
async handle(ctx) {
ctx.session.advance("compute");
const investors = await ctx.callTool("store__investors__query", {
where: { fund_id: ctx.input.fund_id, status: "active" },
});
const allocations = computeProRata(investors, ctx.input.amount_usd);
ctx.session.advance("approve");
const approval = await ctx.session.requestInput<{ approved: boolean }>({
type: "distribution-approval",
question: `Distribute ${ctx.input.amount_usd} across ${allocations.length} LPs?`,
inputType: "yesno",
});
if (!approval.value.approved) {
ctx.session.fail("Operator declined the distribution.");
return;
}
ctx.session.advance("record");
for (const alloc of allocations) {
await ctx.callTool("store__distributions__set", { /* … */ });
}
ctx.session.complete();
},
});The replay execution model
You write linear, imperative code; the runtime makes it durable by journaling and replaying it. When the intent pauses (say, at requestInput), the runtime persists the session and unwinds. When the answer arrives, it re-runs handle from the top, but every side effect already in the journal returns its recorded result instead of re-executing. Execution fast-forwards deterministically to the pause point and continues from there.
That model imposes the author rules:
- Wrap any non-deterministic side effect in
ctx.step(name, fn). On first execution it runsfnand journals the result; on replay it returns the journaled value without runningfn. Steps that repeat inside loops can reuse the same name; the runtime disambiguates with a per-name ordinal. ctx.callTool,ctx.callSkill, andctx.callIntentare auto-memoized: call them directly, noctx.stepwrapper needed.- Use
ctx.now()instead ofDate.now()/new Date(), andctx.random()instead ofMath.random(). Both journal their first-run value so replays see the same numbers. - Don't read mutable global state inside
handle. handlereturnsvoid. Results live in the stores the intent writes and in session metadata (ctx.session.setMetadata); the UI reads them back withuseStoreQuery/useSession.
ctx.callSkill deserves a note: the skill runs as a child session. The parent intent pauses (awaiting-child) while the runtime drives the skill to completion, then resumes with the skill's result: an intent can delegate a judgment call to an LLM loop without any special handling in your code. (Child skills currently run to completion; a child that itself pauses for operator input is not supported yet.)
Session controls
ctx.session is how the intent talks to the operator-facing session record:
| Method | What it does |
|---|---|
setTitle(title) / setMetadata(updates) | Operator-facing title; merge arbitrary metadata. |
advance(stepId) | Discrete step transition; pairs with the surface.steps list so the UI can render a checklist. |
setProgress({current, total?, message?}) | Continuous progress within the current step; omit total for a spinner. |
requestInput(opts) | Pause and ask the operator (or any eligible answerer) a question; resumes via POST /api/sessions/{id}/answer. Supports choice/yes-no/text inputs, answerer allow/block lists, and payload edits. |
scheduleAt(datetime) | Pause until a wall-clock time; the scheduler resumes the session. |
awaitWebhook(opts?) | Pause until an external POST hits a runtime-issued URL. Returns the URL synchronously (embed it in an email, hand it to a vendor) and the inbound body as the result. |
transitionToReview() | Move to review-pending; an operator approval completes it. |
complete() / fail(reason) | Terminal states. |
The optional surface block on the definition is what makes the intent show up in the operator's task list: a category, a title template over the input, the step checklist, and a permissions summary.
How intents fire
| Trigger | Reaches | Notes |
|---|---|---|
Chat message matching regex | classic only | The only chat-side trigger. First match wins. |
useIntentRun(name).run(input) | replay only | Calls POST /api/intents/:id/run synchronously; resolves with {sessionId, outcome} once the run completes or reaches an operator-facing pause. |
useIntentTrigger(name).run(input) | replay only | Calls POST /api/enqueue, the queued lane: returns immediately and the SDK watches the session to its outcome. Use for "Run now" buttons that should go through the durable queue in production. |
Automations (useAutomation().schedule) | replay only | A durable binding fires the intent on a cron/interval/one-time schedule; the worker dispatches it through the same run route as useIntentRun. |
ctx.callIntent(name, input) | classic only (callable from both kinds) | Imperative composition: any intent can invoke a classic helper intent (e.g. a shared log-activity) in-process. Runs to completion synchronously; it is not the pausing child-skill cascade. |
On the React side, useIntentRun and useIntentTrigger share the same launcher shape, {run, output, events, status, error, reset}, and both resolve run(...) with the session's outcome. Because replay intents don't return values, the pattern after a run is to refetch:
const analyze = useIntentRun("analyze-submission-action");
async function onAnalyze(id: string) {
await analyze.run({ submission_id: id });
await refetch(); // useStoreQuery(...).refetch() to pull in the rows the intent wrote
}useIntentRun accepts {scopeId, scopeContext} to partition the session and drive connection contextInjection, the same way useChat does; useIntentTrigger currently takes only {scopeId}.
Session types and intents
Discovery is automatic; session_types is a filter, not a registry. A session that runs under a named session type can only dispatch the intents listed in that type's intents array. The filter applies to both kinds, so a chat session type that should trigger a classic intent must list it, and a restricted surface can also narrow which replay intents its runs may target. Sessions without a session type get every loaded intent. A name in the list that doesn't correspond to a loaded intent logs a session_type_unknown_intent_ref warning at session build.
{
"session_types": {
"support": {
"prompt": "…",
"skills": ["triage"],
"intents": ["seed-examples", "analyze-submission"]
}
}
}Typing your intents
Agent templates keep the authoring types vendored under amodal/_types/ so the repo typechecks without installing runtime packages: intent-context.ts holds the classic IntentDefinition/IntentContext contract, and replay-intent.ts holds defineIntent and ReplayIntentContext. The loader recognizes replay intents by a brand defineIntent stamps on the object, so the vendored helper and the real one are interchangeable at runtime.
If your repo does depend on the packages, the canonical imports are IntentDefinition / IntentContext from @amodalai/types and defineIntent / ReplayIntentContext from @amodalai/runtime/intent.
Choosing between the two
Reach for a classic intent when a chat message should run deterministic logic in the turn: seeding, lookups with fixed phrasing, command-style shortcuts. Reach for a replay intent when the action is fired from a product UI or a schedule, when the operator should watch it progress as a task, or when it must pause: for approval, for a timer, for an external system to call back. When both surfaces need the same action, share the logic in _lib/ and keep both intent files thin.