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.

Note

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 running nomic-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 secret OPENAI_KEY (stored the same way as OPENROUTER_KEY below). WITH dimensions = N requests 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:

typeAskscriteriaAnswer
noulis this proposition true?optional: {"true": "…", "false": "…"}probability = P(true)
choicewhich of these fits?required: option name → descriptionchoice, probability = P(choice), probabilities per option
scorehow 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 == true first. A missing path compares as 0. When the call fails there are no answers, so probability > 0.8 stays quiet but probability < 0.2 fires.
  • Write thresholds as decimals. An integer literal compares as an integer: probability > 0 is false for 0.91. Use > 0.0.

On failure, ok is false, answers is absent, and error.code says why:

error.codeMeaning
txco_decide_invalid_withmalformed state/questions/into, or a question beyond the provider’s limits. Nothing was sent.
txco_decide_missing_secretno VERCEL_AI_KEY for this tenant
txco_decide_timeoutthe op’s timeout passed first
txco_decide_provider_http / _provider_net / _provider_parsethe provider refused, was unreachable, or answered garbage
txco_decide_invalid_answerthe provider’s answers broke the contract (a question unanswered, a choice not offered, a probability out of range)
txco_decide_no_backendWITH 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_ms and limits.max_cost_usd cap 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.

Edit this page · View as markdown