# Using Expresso How to actually use the language: install it, run a program from the CLI, or embed the engine in your own code and wire it to a real model and your real systems. - **Grammar & semantics** → [`SPEC.md`](SPEC.md) - **The thesis & guarantees** → [`README.md`](README.md) - **Where it's going** → [`ROADMAP.md`](ROADMAP.md) --- ## 1. Install ```bash npm install https://expresso.meetcoffee.dev/expresso-lang.tgz # the engine + the `expresso` CLI ``` Zero dependencies. It's a single CommonJS file (`expresso.js`) that also runs in the browser (`window.Expresso`). You can vendor that one file if you prefer. (The real-model port shells out to `curl`, which ships on macOS and Linux; the offline mock needs nothing.) --- ## 2. Run a program from the CLI Write a program (`triage.expr`): ```expresso leaf classify(s: Span) -> { kind: enum(bug, question, churn), urgency: enum(low, soon, now) } agent "Triage" { grants { react.add, tasks.create, notify.send, message.send, ai.classify } at most world lives in channel #support memory open_bugs = 0 on message { let c = classify(.message) if c.kind == bug { create_task "Bug: {.message}" assignee: "oncall" due: "+1d" remember open_bugs += 1 } if c.kind == churn { notify "oncall" "Churn risk from {.speaker}" } } } ``` Or the meeting-native shape — one AI read of the transcript fans out into exactly-once actions (this is the whole language in one behavior): ```expresso agent "MeetingFollowUp" { grants { ai.extract_actions tasks.create notes.create notify.send } on meeting ended { let actions = leaf ai.extract_actions(transcript) -> { items: list<{ title: string, owner: string }>, summary: string } for item in actions.items { tasks.create title: item.title assignee: item.owner } notes.create title: "Meeting summary" body: actions.summary notify.send to: meeting.host body: "Meeting follow-up complete." } } ``` Every iteration of the `for` mints its own idempotency key (`…/1:0/create_task`, `…/1:1/…`), so three extracted items are three exactly-once tasks — and a redelivered `meeting_end` recreates none of them. ```bash expresso check triage.expr # type-check: prints each agent's effect signature expresso run triage.expr events.json # run with the offline mock (no key needed) expresso run triage.expr events.json --model openai # run against a REAL model (OPENAI_API_KEY) expresso replay triage.expr events.json # replay from the journal — 0 AI calls, identical ``` `events.json` is a JSON array of [event envelopes](#5-the-event-envelope). With no `--model`, `run`/`replay` use the built-in deterministic mock so they work offline. Pass `--model openai` or `--model anthropic` (key via `OPENAI_API_KEY` / `ANTHROPIC_API_KEY` or `--key`) to route the AI steps through a live model — `--name` overrides the model id. Either way, `replay` reproduces the run with **zero** further AI calls, because every answer is journaled. --- ## 2b. Deploy it — `serve` + real sinks + durability This is what makes Expresso a thing you *run*, not a batch script. `expresso serve` opens an HTTP listener; every `POST` is an event, the agents react, and real actions fire **exactly once**. ```bash expresso serve triage.expr --apply --config expresso.config.json --journal ./state --port 8080 # POST an event (Slack/Stripe/GitHub/cron → here): curl -XPOST localhost:8080 -H 'x-expresso-secret: s3cret' \ -d '{"id":"i1","type":"message","channel":"#support","speaker":"Dana","text":"checkout is down"}' # → { "ok": true, "applied": 2, "pending": 0, "effects": [ … ] } (POST the same id again → applied: 0) ``` Like `run`, `serve` is a **dry-run without `--apply`** (events are processed, actions previewed, nothing fires and nothing is recorded as done). `applied` counts only actions whose sink delivery succeeded; a failing sink shows up as `pending` (and `ok: false`) and is re-delivered from the WAL on recovery. `--host 0.0.0.0` binds beyond localhost for external webhooks (default `127.0.0.1`). **`--apply` + `--config`** route each action verb to a real sink (`expresso.config.json`): ```jsonc { "sinks": { "notify": { "type": "slack", "url": "https://hooks.slack.com/services/…" }, "create_task": { "type": "webhook", "url": "https://api.yourapp.com/tasks" }, // or "http" / "file" / "stdout" / "none" "default": { "type": "stdout" } } } ``` Webhook/http sinks send an **`Idempotency-Key: `** header — honor it on your side (like Stripe) and you get exactly-once end-to-end. Without `--apply`, `run`/`serve` are a dry-run (actions printed, nothing fires). `--secret` requires an `x-expresso-secret` header on every POST. **`--journal `** persists the AI journal, the applied-effects table, *and* per-agent state (`memory`, event dedup) to disk — `journal.json`, `world.json`, `state.json` — so the guarantees survive process death: - Re-running the same events (or restarting `serve`) applies **nothing new** — exactly-once across restarts. - The host uses a **write-ahead log**: an action is recorded as `intent` *before* its side effect and marked `done` *after* it returns. A crash (or `kill -9`) mid-send leaves it at `intent`, so on restart it is **re-delivered** (at-least-once) rather than lost; `done` actions are never re-fired. See [`examples/serve-demo/demo.sh`](examples/serve-demo/) — it proves all of this against a real HTTP receiver through a downstream outage *and* a hard crash. --- ## 2c. Run it INSIDE Coffee — `expresso coffee` The bridge (`coffee.js`) makes an Expresso agent a real member of a Coffee workspace: it polls a messenger channel through Coffee's API, and its actions land in the same workspace — `create_task` becomes a **real Coffee task**, `create_note` a **real note**, `reply`/`say` a **real channel message** (carrying the receipt's idempotency key as Coffee's `client_temp_id`), `notify` a 🔔 channel message. ```bash export COFFEE_BASE=https:// # login URL export COFFEE_EMAIL=… COFFEE_PASSWORD=… # never commit these expresso coffee support.expr --apply --journal ./state # coffee: workspace 'acme' · channel #support · APPLY (real Coffee actions) # → create_task {"task_hash":"UAov…"} ← a real task in your workspace # → reply {"chat_message_hash":"UrMz…"} ``` - The channel defaults to the agent's `lives in channel #…` tag (`--channel` overrides; the channel is created if missing). `--poll ` sets the poll rate, `--once` processes one poll and exits, `--from-start` includes channel history. - Event ids are Coffee's own `chat_message_hash`, so a re-polled or redelivered message is **exactly-once by construction**; the on-disk journal/world/state survive restarts, and a Coffee API failure parks the action as WAL `intent` and re-delivers it on the next boot. - **Loop-safe:** every message the bridge itself sends is remembered and never re-fed, so the agent reacts to humans — never to its own output. - Without `--apply` it's a dry-run: it reads the channel and prints what it *would* do, writing nothing to Coffee. Every Coffee surface is wired: `create_task` (with a **real assignee** when the name resolves), `create_note`, `reply`/`say` (channel message), `react` (a **real reaction on the triggering message**), `notify` (a message that **@mentions** the target, ringing their real notification bell), `dm.send` (a real DM), `calendar.schedule` (a real scheduled meeting), and `contacts.create` (a real contact). Attach to a **meeting** instead of a channel with `--meeting `: transcript segments become `utterance` events (keyed by their own stable hash) and the meeting's end fires `on meeting ends`. `test/coffee.js` exercises every one of these against the exact wire format offline (injected transport), and all of them are verified against a live workspace. ### The click-button demo — `expresso demo` ```bash export COFFEE_BASE=… COFFEE_EMAIL=… COFFEE_PASSWORD=… expresso demo # opens a local panel at http://127.0.0.1:8797 ``` A control panel where each button runs a real scenario against your workspace and streams the resulting Coffee hashes back: **incident** (→ task + reply), **warden** (a leaked secret → real 🔒 reaction + @mention + Urgent task; then a prompt injection that has no verb to obey), **concierge** (one message → contact + calendar meeting + task + DM), **follow-up** (a real meeting's transcript → tasks + minutes), plus **Redeliver** (re-poll everything → 0 new actions) and **Replay** (re-derive from the journal → 0 AI calls). Nothing is simulated but the offline mock model; every hash is a real object you can open in Coffee. --- ## 3. The language in 60 seconds - An **agent** lives somewhere (`lives in channel #support` / `meetings #project`), declares its **entire surface** in `grants { … }` — what it may do (`tasks.create`, `notify.send`, …) *and* what it may observe through AI (`ai.classify`, `ai.summarize`, `ai.match`) — plus a ceiling (`at most pure|model|world`), and reacts with `on { … }` blocks. - **Triggers**: `on someone says /regex/` · `on someone says ~"intent"` · `on message …` · `on silence for 30s` · `on meeting starts|ends` · `on schedule "…"`. - **Actions**: `create_task` · `create_note` · `reply` · `say` · `react` · `notify` · `remember` — or capability-named: `tasks.create title: … assignee: …` · `notes.create` · `notify.send to: …` · `dm.send to: … body: …` · `calendar.schedule title: … at: … with: …` · `contacts.create name: … email: …`. On Coffee these map to real tasks, notes, messages, reactions, DMs, meetings, and contacts. - **AI steps** (the only nondeterminism): a fuzzy match `~"…"`, `summarize(span)`, and typed `leaf` calls — declared top-level or inline (`let r = leaf ai.extract(transcript) -> { … }`). Outputs are schema-checked: `text/number/bool/date/user`, `enum(…)`, `list<…>`, records, optionals (`?`), with `string/int/boolean` accepted as aliases. - **Control flow**: `if/else`, and `for item in r.items { … }` over a `list<…>` — each iteration gets its own idempotency key, so fan-out stays exactly-once. - **Frame refs**: `.speaker`, `.message`, `.match.1`, `transcript` (the meeting so far), and `meeting.title` / `meeting.host`. - The compiler grades every line `PURE ⊑ MODEL ⊑ WORLD` and rejects, e.g., an AI step under `at most pure` (`E_CEILING`), an ungranted action **or ungranted AI observation** (`E_GRANT`), a bogus enum case / record field, or a `for` over a non-list (`E_TYPE`). --- ## 4. Embed the engine (the SDK) ```js const E = require("expresso-lang"); // or: import E from "expresso-lang" // 1. parse + type-check const ast = E.parse(source); const { ok, diagnostics, agentEffects } = E.check(ast); if (!ok) throw new Error(diagnostics.map(d => d.code + ": " + d.message).join("\n")); // 2. open a durable session, wiring YOUR model and YOUR systems const sess = E.session(ast, { model: myModelProvider, // §6 — how AI steps get answered (journaled for you) onEffect: (effect) => myWorld.apply(effect) // §7 — apply real actions, keyed by effect.idemKey }); // 3. feed it your live events (transcription, chat, task changes…) for await (const event of myEventStream) { const { effects } = sess.feed(event); // effects = the new actions taken on this event } // 4. anything is replayable for audit (see §8) ``` `session()` is the streaming runner: `feed(event)` processes one event across all agents and returns `{ spans, effects }` for that event. It owns typing, journaling, dedup, and ordering; **you** own the event source and the effect application. --- ## 5. The event envelope Map your real events to this shape. `id` must be a **stable, source-derived** identifier (`"transcript:"`, `"task::"`) — idempotency and journal keys are built from it. ```jsonc { "id": "m1", // REQUIRED, stable "partition": "ws-42/#support", // ordering scope = (workspace, room|channel) "type": "message", // utterance | message | silence | meeting_start | meeting_end | schedule "speaker": "Dana", // for utterance/message "text": "the export is broken, a #bug", "channel": "#support", // for message; "meetingTitle" for meetings "seconds": 30, // for silence "cron": "every weekday 09:30",// for schedule "t_event": 1719400000 // event time (used instead of any wall clock) } ``` --- ## 6. Plug in a real model (the model port) The built-in **`E.RealModel`** is a production port backed by a real LLM — drop it straight in: ```js const sess = E.session(ast, { model: E.RealModel({ provider: "openai" }), // reads OPENAI_API_KEY // model: E.RealModel({ provider: "anthropic", model: "claude-haiku-4-5-20251001" }), onEffect: (e) => myWorld.apply(e), }); ``` It speaks OpenAI- and Anthropic-compatible HTTP, maps the three request kinds to chat/structured calls, and returns raw values the runtime journals. Options: `{ provider, model, apiKey, transport }`. The `transport` is injectable — pass your own `(url, headers, body) => parsedJson` to route through a gateway, add retries, or test offline (that's how `test/real-adapter.js` exercises the exact wire format with no network). **Or implement your own port** — an AI step calls `model.call(req)` and the result is **journaled automatically**, so replay never re-calls it. Handle three request kinds: ```js const myModelProvider = { call(req) { switch (req.kind) { case "match": // a fuzzy trigger ~"phrase" — return a boolean return llmYesNo(`Does this match "${req.phrase}"? \n${req.text}`); case "summarize": // summarize(span) — return a string return llmSummarize(req.lines); // req.lines = [{speaker, text}, …] case "infer": // a typed leaf call — return a value of req.schema return llmStructured(req.text, req.schema); // validated by Schema.coerce; off-schema is retried/contained } } }; ``` Back these with structured-output / function-calling (this is the BAML idea). The runtime type-checks the `infer` result against the declared `leaf` schema, so a malformed answer never escapes the step. (The reference runtime is **synchronous**, so `model.call` must return a value, not a Promise — `RealModel`'s default transport uses a blocking `curl`. A fully async streaming runner is on the roadmap; until then, drive an async model by pre-resolving into the journal.) --- ## 7. Plug in your world (the `onEffect` hook — exactly-once) Every action the agent takes is handed to you as a receipt; apply it to your APIs **keyed by `idemKey`** so retries and replays never double-act: ```js const myWorld = { apply(effect) { // effect = { id, idemKey, verb, payload } // verb ∈ create_task | create_note | notify | reply | say | react return myApi[effect.verb](effect.payload, { idempotencyKey: effect.idemKey }); } }; ``` `idemKey` is a stable string like `m1|Triage#0/1/create_task|task`. The runtime already dedups within a session; persisting `idemKey` on your side closes the crash window (effect applied, process dies before recording) — that service-side table is the durable exactly-once guarantee (ROADMAP Phase 7). --- ## 8. Audit & replay Re-run any history from its journal to reproduce *exactly* what happened — same AI answers, same actions — with the model port disabled: ```js const r = E.run(ast, log, { model }); // first run, captures journal + effects const rep = E.replay(ast, log, r.journal, r.worldTable, r.effects); // rep.ok === true → 0 model calls, 0 actions fired twice, effects bit-identical to r ``` Every run also produces a `trace`: per firing, which trigger fired, each AI step with its journaled input/output and provenance (`fresh` vs `journal`), and each action with its idempotency key. That trace is your audit log — and it's replayable. --- ## API reference | Call | Returns | |---|---| | `E.parse(source)` | the AST (throws a friendly syntax error with line:col) | | `E.check(ast)` | `{ ok, diagnostics:[{code,message,line}], agentEffects:{[name]:{grade,caps,modelLeaves,replayable}}, grades }` | | `E.gradeByLine(ast)` | `{ lines:{[lineNo]:"pure"|"model"|"world"}, check }` — the editor gutter source | | `E.session(ast, opts)` | `{ feed(event)→{spans,effects}, journal, worldTable, effects, trace, state, modelCalls, check }` | | `E.run(ast, log, opts)` | drives a session over a whole log → `{ trace, effects, journal, worldTable, state, modelCalls, check }` | | `E.replay(ast, log, journal, worldTable, refEffects)` | `{ ok, identical, modelCalls, newEffects, reproCount }` | | `E.RealModel(opts)` | a live model port — `{ provider:"openai"\|"anthropic", model, apiKey, transport }` | | `E.Schema.coerce(type, raw)` / `E.MockModel()` | schema validation / the deterministic offline model | **`opts`** for `session`/`run`: `{ model, journal, worldTable, state, onEffect, stopAfter }`.