All docs
Docs · Stacks
AI Operations
Using EXEC ai://chat
In Thanks, Computer, an AI model is just another operation: gated by a resonator, reading the shared document, merging its answer back.
One rule puts a model in the flow:
WHEN @src == "lmtp"
WITH prompt = "Classify this support mail: {{@lmtp.msg.text}}",
system = "You answer with one word: billing, technical, or other.",
model = "openai/gpt-4o-mini",
intent = "classify_support_ticket"
EXEC "ai://chat" The model’s reply merges into the document as .text — and whatever
resonates with that at the next step, fires. The classifier doesn’t
route; it just answers, like every other op.
ai://chat needs an OpenRouter API key, stored as the secret OPENROUTER_KEY.
Set it before your first call — see Set the API key below.
Prompts read the document
{{@path}} markers in prompt and system are filled from the
op’s input — {{@web.req.body}}, {{@lmtp.msg.subject}} —
JSON-escaped automatically so values splice into the prompt without
breaking it. Missing paths render empty rather than failing. On a rule
with a SELECT clause, the input is the projected view, so select the
paths the prompt references. For a computed scratch value, use a plain SET (before any SELECT, a SET decorates this op’s input only,
without propagating downstream):
SET @summary_input = .order.notes
WITH prompt = "Summarize: {{@summary_input}}"
EXEC "ai://chat" For full control, skip prompt/system and author the conversation
directly: WITH messages = [...] (OpenAI-style role/content turns).
A long system prompt can live in its own file beside the op: WITH system = &include("system.md") (Including files).
It is still a template: its {{@path}} markers are filled like any
other, and any other {{…}} in it (a Handlebars example, say) is an
error, so keep such text out of included prompts.
Structured output
WITH schema = {...} (a JSON Schema) switches to structured-output
mode: the chassis validates the model’s reply against the schema and
merges the validated object as .schema_validated_payload. On
failure — or any provider error — the op contributes .chat.error instead, and your next step’s rules can resonate on that.
Embeddings — EXEC "ai://embed"
Where ai://chat returns prose, ai://embed returns a vector — the numeric
fingerprint of a piece of text, for semantic search. It’s the companion to the vector store: embed the query, then search.
WITH provider = "openai",
model = "text-embedding-3-small",
text = "a cozy book for a rainy afternoon"
EXEC "ai://embed" The vector merges in under _embed.vector (a float array), alongside _embed.{model, dimensions, tokens}. Embed many strings in one call with WITH texts = [ … ] → _embed.vectors (one per input). On any provider error
the op contributes _embed.error instead, and your next step can resonate on it.
Two backends ship in the box:
provider = "ollama"(the dev default) — a local Ollama runningnomic-embed-text, no API key. Point it with--embed-ollama-base-url.provider = "openai"— OpenAI’s embedding models (text-embedding-3-small/-large); needs the per-tenant secretOPENAI_KEY(stored the same way asOPENROUTER_KEYbelow).WITH dimensions = Nrequests a shortened vector where the model supports it.
Pick one embedding model per collection and stay on it — vectors are only comparable within the same embedding space, so a collection pins its model and rejects a mismatched upsert.
Decisions — EXEC "ai://decide"
Some questions don’t need prose back. Which folder does this belong in? Was
this sent by a machine? How urgent is it? ai://decide asks them directly:
you give it the evidence (state) and a set of bounded questions, and each
answer comes back typed, with probabilities. The model can only pick from the
answers you offered.
WHEN ._doc.ready == true
WITH state = ._doc.summary,
questions = &object(
"folder", &object("type", "choice",
"instructions", "Which folder does this document belong in?",
"criteria", &object("Invoices", "bills and receipts",
"Contracts", "signed agreements")),
"automated", &object("type", "noul",
"instructions", "Was this sent by a machine rather than a person?")),
into = "_place",
timeout = 5000
EXEC "ai://decide" state is data: a string, an object, or an array (never a template). There
are three kinds of question:
type | Asks | criteria | Answer |
|---|---|---|---|
noul | is this proposition true? | optional: {"true": "…", "false": "…"} | probability = P(true) |
choice | which of these fits? | required: option name → description | choice, probability = P(choice), probabilities per option |
score | how far along this rubric? | required: levels, lowest first (≥ 2) | score (0 = lowest level), probabilities per level index |
When the provider reports its own confidence in an answer, that rides along
too (Jev reports one for choice and score, not noul). It is the
provider’s number, not a probability, and it is never made up when absent.
The result lands under WITH into (default _decide):
{
"ok": true,
"answers": {
"folder": {
"type": "choice",
"choice": "Invoices",
"probability": 0.91,
"confidence": 0.93,
"probabilities": {
"Invoices": 0.91,
"Contracts": 0.09
}
},
"automated": {
"type": "noul",
"probability": 0.04
}
},
"provider": "vercel",
"model": "typesafe-ai/jev",
"usage": {
"input_tokens": 275,
"output_tokens": 20
},
"latency_ms": 180
} Your stack decides what a probability means
The op has no threshold, fallback or allow/deny setting. It returns judgments; the next step’s WHEN clauses turn them into policy, where they are visible and diffable:
WHEN ._place.ok == true && ._place.answers.folder.choice == "Invoices"
&& ._place.answers.folder.probability > 0.80
EMIT .folder = "Invoices" WHEN ._place.ok != true
EMIT .folder = "Inbox" The failure lane is yours too: fall back, fail closed, ask a human. The op never turns a failure into an answer. Two WHEN rules matter for every threshold:
- Gate on
ok == truefirst. A missing path compares as 0. When the call fails there are no answers, soprobability > 0.8stays quiet butprobability < 0.2fires. - Write thresholds as decimals. An integer literal compares as an
integer:
probability > 0is false for 0.91. Use> 0.0.
On failure, ok is false, answers is absent, and error.code says why:
error.code | Meaning |
|---|---|
txco_decide_invalid_with | malformed state/questions/into, or a question beyond the provider’s limits. Nothing was sent. |
txco_decide_missing_secret | no VERCEL_AI_KEY for this tenant |
txco_decide_timeout | the op’s timeout passed first |
txco_decide_provider_http / _provider_net / _provider_parse | the provider refused, was unreachable, or answered garbage |
txco_decide_invalid_answer | the provider’s answers broke the contract (a question unanswered, a choice not offered, a probability out of range) |
txco_decide_no_backend | WITH provider names no registered backend |
Each question key comes back under the same key, so keys are limited to
letters, digits, _ and -. The one backend today, provider = "vercel" (the default), sends the questions to Vercel AI Gateway’s evaluation API. It
runs TypeSafe AI’s Jev (typesafe-ai/jev) by default and accepts up to 255
options per choice. It needs the per-tenant secret VERCEL_AI_KEY,
stored the same way as OPENROUTER_KEY below. Decision state is often private
(mail, documents), so the chassis asks the provider not to retain it
(--decide-zero-data-retention, on by default). Jev honors that; a model
without a zero-retention agreement refuses the call rather than serving it.
Decisions take well under a second (about 300 ms warm), so set a tight WITH timeout; the AI default is 60s.
To test a flow without calling a provider, mock the op with the result shape above (what the op merges), not the provider’s wire format.
A worked example lives at examples/decide-produce/: type a food, and four
questions plus four WHEN rules answer “vegetable”, “not sure”, or “not
produce”.
Set the API key
ai://chat routes through OpenRouter, so the chassis
needs an OpenRouter API key. Store it as a per-tenant secret named OPENROUTER_KEY with the secret-store CLI —
you paste the key at a hidden prompt, so it never touches your shell history:
txco auth tenant secrets set OPENROUTER_KEY
# add --tenant <slug> to target a specific tenant Grab a key from openrouter.ai/keys. The chassis materializes it into the op at call time; the cleartext never reaches traces, logs, or continuations.
On a dev machine you can skip the secret store and export the key instead — the
chassis falls back to a same-named environment variable
(--ai-chat-env-fallback, default on):
export OPENROUTER_KEY=sk-or-... Set --ai-chat-env-fallback=false on shared deployments so each tenant must
provision its own key.
Keys, costs, and accounting
- Secrets come from the secret store. The
backend declares what it needs (the OpenRouter backend:
OPENROUTER_KEY) and the chassis materializes it per tenant. Cleartext is contained by construction — it can’t reach traces, logs, or continuations. - Telemetry rides the envelope. Every call stamps
_txc.chat.{provider, model, tokens.in, tokens.out, latency_ms, retries}— visible in the trace, aggregatable for cost reporting. Token counts are not charged to fuel: provider compute is its own dimension. - Limits per call.
WITH limits.timeout_msandlimits.max_cost_usdcap one call; the chassis-wide default timeout for AI ops is--ai-default-timeout(60s — deliberately longer than the 5s op default).
The other direction
Everything above is outbound: your stack calls a model. The chassis also runs an inbound AI gateway — point an existing AI client at it and the
requests it sends become operations, which a stack can reject, rewrite, or answer with
context the model didn’t have. Different direction, different keys: the gateway reads the
tenant secret ANTHROPIC_KEY, never an environment variable.