keel
Turns

Starting a turn

Supply the request, agent, trusted context, and execution grant.

envelope
{ threadId, message,
  clientTurnId, attachments }
application
{ agent, ambient,
  budget: { steps: 8 } }
runtime.runTurn(...)
→ admit → assemble → execute → settle
The request supplies the task. Your application supplies the agent, trusted context, and grant.

Your application decides which agent handles a request and what work it may perform. runTurn starts that invocation; it does not choose the agent or infer an execution budget from the user's message.

Open a root request

This example uses the starter app's factory:

import { createApp } from "@app/app.js";

const app = createApp();
const outcome = await app.runtime.runTurn({
  agent: app.rootAgent,
  ambient: app.defaultAmbient,
  budget: { steps: 8, timeMs: 30_000 },
  envelope: {
    threadId: app.defaultAmbient.threadId,
    message: "What happens inside a turn?",
    clientTurnId: "request-1",
    attachments: [],
  },
});

For a deployed application, derive ambient context from your authenticated request and selected workspace. The starter's defaultAmbient is a local example configuration, not an authentication mechanism.

Separate request data from ambient context

InputRole
agentThe definition whose prompt, chain, tools, reads, and emits this turn uses.
ambientThe shared core (userId, threadId) plus your app-declared scope — plans, tenancy, roles — typed by defineAmbient and validated at admission.
envelopeThe root request's thread ID, message, client turn ID, and attachment references.
budgetExplicit ordinary steps, an optional time ceiling, and an optional cumulative output-token grant.
hooksOptional output callback (onEvent) and cooperative cancellation signal.
deliverableA contract this root turn is opened under; completed then requires a validated submission of the named part.
continuesFromRecords a relationship to an earlier turn on this turn's record; it restores nothing.

Ambient values inform access checks and context rendering. They should come from trusted application logic. A client's message cannot grant itself a higher plan or another organization's access.

clientTurnId is part of the request shape; it does not currently provide idempotency or deduplication. Attachment references also do not load file contents automatically. Your application must resolve and expose needed data.

Understand admission

The ambient rides in first: userId and threadId must be non-empty, and ambient.scope is parsed against the schema your app declared with defineAmbient. A scope that does not parse is refused at the door as failed with ambient-invalid — never discovered downstream.

A supplied root envelope is schema-checked, must name the ambient thread, and must fit the configured request-size ceiling (measured in UTF-8 bytes). A thread admits one active root turn at a time. A concurrent root on that thread receives failed with admit-refused.

That one-root-per-thread claim lives in the store — claimThread and releaseThread on the Store interface. The default MemoryStore keeps it in-process; a durable store adapter that makes the claim atomic serializes root turns across multiple runtime instances behind a load balancer too. A mismatched envelope instead fails with envelope-invalid, before model execution.

Use a brief for programmatic work

runTurn also accepts brief without an envelope. This can open a root turn for application-initiated work; delegation uses a brief for its child input. The runtime does not append a user conversation message for a brief-only invocation.

When both are provided, brief supplies the model request while the envelope's message is stored as the user message. Prefer one clear input path unless your application deliberately needs those different values. Use asTool for delegation rather than manufacturing parent identifiers yourself.

Open a root turn under a deliverable contract

Deliverable contracts are not only for delegation. A root turn can be opened under one, and the typed overload narrows the result: completed then carries Delivered<T>, not Delivered<T> | null.

const outcome = await runtime.runTurn({
  agent: researcher,
  ambient,
  brief: "Summarize decree 42",
  budget: { steps: 6 },
  deliverable: { part: findingsPart, nudges: 1 },
});

if (outcome.kind === "completed") {
  outcome.deliverable.data; // typed by findingsPart's schema
}

The overload returns DeliveredTurnOutcome<T>, inferred from the part's schema. The contract's part must be registered in the runtime's PartRegistry, or runTurn throws a KeelError before the turn opens. See Completion and deliverables for how the contract is enforced.

Observe output without inferring success

Use hooks.onEvent for parts emitted by this invocation and await the returned outcome to learn how it ended. Child output is private by default. Cancellation uses hooks.signal; see Budgets and cancellation.

The last part your hook sees is not on the payload

After the turn settles, hooks.onEvent receives one final, framework-reserved outcome part carrying the outcome kind. It is a wire signal for live consumers and is not included in payload.parts — apps can neither emit nor forge it.

Inspect in Studio

Run the example request and check TURNS for its gates and grant, then CONTEXT for the assembled request. Test an invalid envelope separately and confirm admission fails before a model step appears. Studio's sidebar edits the ambient scope as JSON — it is validated at the admit gate on every run, so an invalid scope shows you ambient-invalid live.

Next: Inside a turn.