Skip to content

Design notes

A tour of how each part of the platform works and what is built, with links to the document that designs it. The roadmap says what is next.

Who may do what is written up in docs/authorities.md: authorities are the fixed vocabulary in code, roles are workspace-owned rows that bundle them, a token carries role names only, and the API resolves them on every request through a per-workspace cache invalidated over LISTEN/NOTIFY.

Tenancy, whose credential pays and what is attributed are written up in docs/workspaces.md — the short version being that workspace_id is the isolation boundary and stays that way, with organizations added above it rather than nesting beneath it.

Running a release rather than a checkout -- the images CI publishes once it has passed, k8s/deploy pointed at an external Postgres and object store, an infrastructure repository pinning a commit, and the order an upgrade rolls the tiers in -- is docs/deployment.md.

Storage layout and retention are written up separately, in docs/storage.md — including why the object prefixes are ordered scope-first rather than as the hierarchy you would expect, which looks like a mistake until you know about S3's per-bucket lifecycle rule cap.

How an agent reaches anything outside the platform -- and why the tier running workspace code holds no credential and opens no socket -- is in docs/egress.md. It also has the design for reaching a service inside the cluster, which is refused today -- an escape hatch rather than the usual path, since a customer's service normally has a public name and needs none of it. Where one genuinely does not, the guard that stops a workspace aiming the gateway at outturn-api stops that too, and the operator's list is what separates them.

Who supplies those hosts and the credentials that go with them is a separate question, designed and unbuilt in docs/integrations.md: the operator installs their own APIs, approves extensions a workspace can turn on for itself, and may optionally let a workspace add its own hosts -- which is today's behavior, so that tier is a restriction to add rather than a feature. None of the three waits on a new tool. fetch_url already reaches any host a workspace is allowed, so an integration is a permitted host, a credential bound to it, and a skill saying what to call -- and the work is in the rules and the credential rather than in the guest. A typed tool would be checked by the same code as a fetch_url call to the same place, so it adds no containment; what it would add is argument shape and argument constraints, which docs/roadmap.md records as a fork to take when an integration needs them.

Every model call is a row in the usage ledger, tagged with workspace, agent, session, user, the workspace's own account label, the model that actually served, and whose key paid; the export at /v1/usage is what bills are built from. Written up in docs/usage.md.

Which model answers, whose key pays and how fallback works across workspaces that bring their own keys is in docs/routing.md; how defaults cascade from operator to workspace to agent with an explicit override at each level is in docs/settings.md. Both are mostly design: each says what exists.

Which models a deployment has, and what each accepts, is docs/models.md, built but for Bedrock: a catalog from models.dev shipped in the image and checked weekly; provider accounts with sealed keys, models pinned or tracking the latest in their line, and a chain per traffic class, all configured on Platform → Models or made by a recipe on Platform → Setup with a test call; settings chosen per model; an upgrade the settings would not survive waiting in the operator's inbox; and a note under any reply a fallback answered. The gateway takes each model's facts from the catalog rather than its name. How a model's reasoning is asked for, shown, kept and given back is docs/thinking.md; asking and showing are built, keeping a thinking block's signature through every tier is not.

Starting a turn when nobody is typing is in docs/triggers.md — schedules and webhooks being built, email designed and deferred. An inbound delivery is authenticated by hmac or by shared_secret, and under hmac it is recorded so the same signed request cannot be spent twice: the record is anchored to the signed timestamp rather than to arrival, and released again on any path that does not start a turn, so a delivery the ceiling refused can still be retried. shared_secret is exempt, because it binds no time and a record keyed on token and body would refuse a sender's legitimate duplicate.

Two labels rather than one: the owner who set a trigger up is recorded for accountability, while user_id stays null because nobody is waiting, which is also what stops an agent clearing its own stopped-session latch.

Turning an API specification into a skill is built, designed in docs/openapi-wizard.md — a manifest in the prompt and a file per operation in the object store, because a skill body is paid for on every round of every turn and a real specification is megabytes. The agent reads an operation with read_object when it needs one, which is the same trade load_tools already makes for the guest's own tools.

The wizard and its page are built, and what it makes is a derivation -- a stored specification plus annotations keyed by operation (notes, preferences, hidden operations, approval rules; observed examples are still to come) -- so an updated specification proposes a new version that keeps everything people added and reports what no longer applies. Agents still read only published versions; annotations never reach a turn. In the same document, under "A derivation, not an output".

A skill as a body plus files versioned together -- a package -- is in docs/skill-packages.md, built. A version carries its files, a fork copies them, and an agent reads them as skill/<slug>/<path> from the version its binding resolved to.

Several skills shipped and versioned as one thing -- a bundle, "Accounts Receivable" rather than any one skill in it -- is designed and unbuilt in docs/skill-bundles.md. It wants integrations first: a bundle whose hosts and credentials a workspace must still arrange by hand cannot do its job.

How somebody finds out their skill is not working is designed but unbuilt, in docs/skill-evaluation.md — a skill that documents its call the way an API's own docs do ("GET https://…") reads fine to a capable model and gets called as a tool name by a weaker one, and nothing today would say so. Most of that is a query rather than an inference; the judged half reads untrusted transcripts, which is the part to be careful with.

Finding a conversation again is in docs/session-search.md, partly built: the recent list by last activity rather than every session ever made exists, paged in the sidebar and searchable by title, and so does /v1/agents/activity beside it. Still designed only: searching message content, and embeddings as an optional component like Tika. They go through the gateway rather than beside it: an embedding is a model call, and a model call that skipped the ledger would be the first.

What happens to a write nobody saw the answer to is designed but unbuilt, in docs/idempotency.md — a tool call has three outcomes rather than two, and the third, sent-but-never-observed, is what a crash creates however carefully a turn is written. Not what a stop creates: stopping only happens at a round boundary, and a round cut partway has its tool calls refused wholesale, so the deliberate path avoids the window rather than recording it.

There is a stop button now, and it avoids it by never stopping inside a round. POST /v1/agent-sessions/{id}/cancel records the request on the job row; the gateway sees it within CANCEL_POLL and cuts the provider stream, which closes the upstream connection and is the only thing a provider understands as "never mind"; the guest hears about it through limits.cancelled and returns at its next round boundary, keeping whatever it had written.

Stopping work for a reason other than somebody clicking stop -- a spend cap, an operator, a turn waiting on an approval -- is in docs/inhibitors.md. Zero or more holds, each contributing suspended or stopped, with the strongest winning and the verdict derived rather than stored. Stopping is built: the strength and verdict model, decide, a Postgres store, /v1/inhibitors, and enforcement in the worker and the gateway.

Suspension now parks. A suspended verdict moves the job to parked -- a sixth state -- dropping its lease so the reaper leaves it, and giving back the attempt it spent so a turn suspended repeatedly does not fail for want of retries. Releasing the hold calls jobs::resume_parked, scoped the way the hold was, and the turn runs again and re-evaluates every hold: one another hold still covers parks a second time, which costs a claim and is the right way round to be wrong. prepare_turn returns Prepared::{Run, Nothing, Park} rather than an Option, so the call site cannot fold parking back into "nothing to do" -- which is what made resumable a promise with nothing behind it.

A suspended hold is taken by api::gated, when the gateway refuses a request a skill declared as needing approval. The stop endpoints still write Strength::Stopped, which is right: an operator stopping a workspace is not asking a question.

A hold taken while a turn is already streaming is the case prepare_turn cannot see, since that turn was claimed before the hold existed. The guest hears about it at its next round boundary, finishes what it holds and returns; the job then parks rather than completing, keeping the reply it wrote, so answering the approval has a turn to give back. Such a turn must not latch its session -- latching is what makes a stop need a person, and an approval is lifted by one, so latching would leave the conversation stopped after the yes. awaiting_approval on the turn's result is what tells them apart.

parked is a job state, so it is enumerated in the places the invariant below warns about: the accounted-for list in chat::postgres (left out, the first message sent while an approval is pending wedges the session), the stop button's lookup and its index, request_cancel (cancelled outright, like a pending job -- no runtime holds it), and the browser's job_state union, where it renders as held so a reload says what the live chat.held event said -- and since, the SQL function live_turn that says what a session's live turn is doing, and the predicate of jobs_live_turn_workspace_idx, which sessions::agent_activity repeats word for word to be served by it. job_backlog is deliberately untouched: a parked turn cannot be claimed, so counting it would ask for pods to run work nobody can take.

What declares that an operation needs approving, and what a yes is worth once given, is in docs/approvals.md -- the rule lives in the skill file that documents the operation, as frontmatter, because skill-packages.md made those files versioned, immutable and writable only under SkillsWrite. Who gets asked once a request is pending, and how they find out, is in docs/action-queue.md -- partly built, ahead of the advice in inhibitors.md that said to wait for something to notify about. The queue exists with role-valued targeting and one read across every workspace a person belongs to; what it still points at is an event rather than the hold, and it carries a lifecycle column the hold should own. That document settles which is the truth and what changes when suspension lands. Nothing should start reading action_items.state as the answer to whether a request is open.

A refused call is held whole, and an approved one is sent by the platform. The gate answers the guest with "Do not retry this request", which is right while the turn is stopping; once somebody says yes, the next turn's runtime sends the held request through the gateway before the guest runs, and the model reads "Approved by ... and sent:" and the response where the refusal was -- it never writes the approved call again, so it cannot write a different one. A request that never left is tried again; one that may have landed goes back to a person unless the recipient dedupes it. See The approval carries the call in docs/approvals.md, and "What approvals already assume" in docs/idempotency.md.

A round cut partway is therefore untrusted in full: its tool calls may carry arguments truncated mid-JSON, and running one is precisely the outcome that document is about. They are refused the same way a reply cut off at the token limit has always been refused — see the truncation guard in agents/default/src/lib.rs.

Letting a workspace stop being asked about a gate it trusts the agent with is built in docs/auto-approval.md: policies over the operator's declared risk and auto ceiling, conditions only on the gate's bound fields -- never the agent's own judgment -- decided at the gateway and recorded there before the request goes out. Configuring that, and the rest of a workspace, through an agent is in docs/admin-agent.md, and its one rule is that the agent proposes and a person confirms through an endpoint its token cannot reach.

Words this codebase uses in a particular way are in docs/glossary.md -- worth a look before a design conversation, since a few are easy to misread — and two of them recently swapped. A package is one skill shipped as a body plus its files; a bundle is several skills shipped together. Until recently "bundle" meant the first, so anything written before the swap that says "bundle" for one skill means package.

Direction

What is next and what blocks what is in docs/roadmap.md, which links each item's design where one exists. The short version is that little blocks anything: an integration is a permitted host, a bound credential and a skill saying what to call, and fetch_url already serves all three. It also records why tools living inside the guest is a fork to take later rather than the prerequisite it first looked like.

Intended but not yet built, so that nobody mistakes these for facts about the code: Redis caching, OpenTelemetry, and workflows as scripted tasks in sub-sessions.

A workspace's egress list is managed through /v1/egress-rules, and from Settings > Connections, which allows and removes hosts and connects each one's key. The agent and platform levels have no UI.

There is no per-workspace fairness in the queue, on purpose for now. Priority classes put a waiting person ahead of background work; within a class, order is arrival. One workspace's burst can therefore sit in front of another's until the autoscaler catches up, and the bet is that it catches up fast enough for this not to matter. If that bet fails, the fix is a fairness term in the claim's ordering, which means changing the claim index and the backlog view together.