Sessions
Give an action a session: durable identity, ordered turns, an end, schedules, approvals, and held turns.
A session gives an action continuity across turns: an immutable input, handler-owned state, input history, and a lifecycle. The session block declares how sessions are identified, when each one ends, and the clock it keeps, and each trigger declares whether a matching event starts a session or resumes one. The same action and session ID always address the same session.
A matching start event creates the session, or is delivered to it when it is open with the same input; a start with different input for an open session is a conflict, and a closed session ID never starts again. The session's origin is the earliest-committed start event delivered to it, and startedAt is that event's commit time, even when a start that committed earlier is delivered after the session was created; the session's end moves with it. A matching resume event is delivered to each open session it names. An event that fails to start or resume a session, such as a start with conflicting input, is recorded as an intake record. See Session turns. Calls to a tool with a session record their history.
| Field | Type | Meaning |
|---|---|---|
inputSchema | JSON! | JSON Schema 2020-12 for the session's initialization input, session.input. It must have type: "object". External references are not supported; references into $defs are. |
id | String! | CEL expression over session.input that evaluates to the session ID, a nonempty string. |
ends | String | CEL expression over session.input and session.started_at that evaluates to a timestamp: when each session ends, such as session.started_at + duration('720h'). Before it, no turn may complete the session; at it, the session gets a final turn, or completes with the reason ended when nothing is pending. Omitted: a session ends only when a turn completes it or an operator closes it. See Session end. |
schedule | [ActionSessionScheduleInput!] | Each session's own clock: up to 8 named entries whose firings are inputs to that session alone. See Session clock. |
turns | ActionSessionTurnsInput | When a session works: which inputs start a turn, and how many one turn takes. Omitted uses the handler kind's defaults. See Session turns. |
| Schedule field | Type | Meaning |
|---|---|---|
name | String! | Names the entry: lowercase letters, digits, _, and -, starting with a letter, at most 64 bytes, and unique among the action's triggers and schedule entries. turns.on names entries as it names triggers. |
cron | String | A standard 5-field cron expression, as for a schedule trigger. It fires at each matching time after the session started. |
timezone | String | The IANA timezone cron is evaluated in. Omitted is UTC. Only with cron. |
every | Duration | In place of cron: fires every period from the session's start, such as "24h". Whole minutes, at least 1 minute. |
Each entry has exactly one of cron and every.
| Turns field | Type | Meaning |
|---|---|---|
on | [String!] | Names of the triggers, or schedule entries, whose inputs start a turn. An input from another trigger or entry is delivered to the session as usual and waits for its next turn. Omitted or empty: every input starts a turn. Approval responses always do. It must name a schedule entry, or at least one trigger that can deliver to every open session: a start trigger, a resume trigger by id, or a resume trigger by a session.input field the input schema requires. |
window | Duration | After the session has been idle, how long the first input that starts a turn waits for more to arrive, so a burst is one turn. At most 24 hours. Firings of the session's clock and the session's end never wait for it. |
maxInputs | Int | Most inputs one turn takes, at most 1000; 1 is one input per turn. Omitted or 0 uses the handler kind's default: every pending input up to 1000 for agents, one for Starlark actions. |
maxBytes | Int | Most event bytes one turn takes, at most 1 MiB, except that a turn always takes at least one input. Omitted or 0 uses that ceiling. |
Session roles
With a session block, every trigger has a session role with exactly one of start or resume. Without one, no trigger or tool may declare a role and no expression may read session.
| Role | Meaning |
|---|---|
start: {input} | Finds the session, or creates it. input is a JSON object mapping each initialization field to a CEL expression over event; the resulting object is validated against inputSchema and becomes session.input. A literal string needs a CEL string literal, such as "'monitoring'". |
resume: {id} | Delivers the event to the open session whose ID is id, a CEL expression over event that evaluates to a string. Never creates a session. |
resume: {input} | Delivers the event to every open session whose session.input field equals a value. input is a one-entry JSON object mapping a top-level property of inputSchema to a CEL expression over event. The value must be a string, integer, boolean, or null; convert other values, such as UUIDs, with string(). |
A resume trigger's event predicate may also read session.id and session.input; those terms are checked against each session the key names. It must also constrain the events it selects through event fields, such as event.type; a predicate that would select every event is rejected. A start predicate and its mapping cannot read session, which may not exist yet. session.state and other session fields are not available to expressions. Paths into session.input must exist in inputSchema when it disallows additional properties. A start mapping must cover every property inputSchema requires, since defaults are not inserted.
An action with a session needs a start trigger or a starting tool session. Each session keeps the action version it started with, so later definition updates do not change the triggers, schemas, tools, end, or clock of existing sessions.
mutation CreateMonitor($id: UUID!) {
actions {
createAction(
id: $id
input: {
name: "early-fraud-detector"
kind: "agent.echo"
config: { agent_id: "monitor", sleep_ms: 0 }
triggers: [
{
name: "opened"
event: "event.type == \"account.inserted\""
session: { start: { input: { account_id: "string(event.data.account_id)" } } }
}
{
name: "entry"
event: "event.type == \"entry.inserted\""
session: { resume: { id: "string(event.data.account_id)" } }
}
]
session: {
inputSchema: {
type: "object"
properties: { account_id: { type: "string", format: "uuid" } }
required: ["account_id"]
additionalProperties: false
}
id: "string(session.input.account_id)"
turns: { on: ["entry"], maxInputs: 100 }
}
}
) {
actionId
definition {
session {
id
turns {
on
maxInputs
}
}
}
}
}
}
Session turns
A session advances in turns. A turn is one pass of the action's handler over a fixed set of the session's pending inputs; for an agent, that is its whole loop of model calls and tool calls, up to its outcome. Turns are numbered from 1 and run one at a time.
Each event is delivered to a session once, however many of its triggers match, and waits in the session until a turn takes it. Events that arrive while a turn runs wait, and the next turn takes every pending input up to the turns limits, in the order they were delivered. With turns.on, only an input of a trigger it names starts a turn; any other input waits for that turn, which takes it in the order it was delivered. In the example above, opening an account starts the session, and its first entry starts the first turn, which sees both. window delays only the first turn after the session has been idle, and never a turn that a firing of the session's clock or the session's end starts.
The handler receives the session's ID, input, current state, and the turn's inputs, and returns the session's next state and status: OPEN, WAITING, COMPLETED, or FAILED; a session with an end limits when it may report OPEN and COMPLETED. The new state, the status, the turn's output, and the consumption of its inputs commit together, or not at all, so turn N+1 always builds on what turn N committed. A turn runs as one execution, and every retry of that execution receives the same inputs and state. If the execution fails after its retries, or with an error that is not retried, the turn is FAILED and so is the session: its inputs remain, unconsumed, and the reason names the execution. An agent's turn that the model refused, or that did not report an outcome, is held instead: the session waits for a person's answer. COMPLETED, FAILED, and CANCELLED are final; later events are not delivered.
A turn reads as an ActionSessionTurn:
| Field | Type | Meaning |
|---|---|---|
id | ID! | Global identifier for the node(id) query. |
actionId, sessionId, number | UUID!, String!, Int! | The session, and the turn's number within it, from 1. |
status | ActionSessionTurnStatus! | RUNNING while it runs or waits to retry, COMMITTED once it succeeded, FAILED if it failed without committing, HELD if it failed without committing and its session waited for a person's answer (see Held turns), and CANCELLED if the session was cancelled before it could commit. |
inputs | [ActionSessionTurnInput!]! | The turn's inputs, in the order they arrived. Each carries its event, unless the inputs together exceed the size delivered inline; read the rest through the session's inputs. |
checkpoint | ActionSession | The session as the turn left it: the version it committed, with the state, status, and reason it wrote. Null unless COMMITTED. |
final | Boolean! | Whether this is the session's final turn: it started at or after the session's end and takes every input then pending. Its outcome must complete the session. |
endsAt | Timestamp | The session's end when the turn started, or null if it has none. |
executionId, execution | UUID!, ActionExecution | The execution that runs the turn: its attempts, transcript, tool calls, and output. |
created | Timestamp! | When the turn started. |
A turn's execution has no event. Its turn field reads the turn it runs, and its session field the session. A replay of a turn's execution also reads that turn through turn, and receives its inputs, firings included, and its final and endsAt as the original turn had them.
While its turn runs, a session action's credential can read its own session, that session's turns, history, and inputs. It cannot read other sessions, list sessions, or change any session.
Session end
session.ends gives each session an end. It is evaluated when the session starts, over the session's input and started_at, and the result is the session's endsAt. When a start event that committed earlier moves the session's startedAt earlier, endsAt is evaluated again and moves with it. An ends that fails to evaluate for a session's input, or does not produce a timestamp, fails the start: no session is created, and the event is recorded as an intake record.
- Before its end, no turn may complete the session. A turn may keep it
OPEN, or leave itWAITINGfor an approval; a turn of any handler that reportsCOMPLETEDfails withaction_output_invalid, and an agent is offered onlyOPEN. An operator can still close the session withcloseSession. - At its end, a session with pending inputs gets a final turn at once, marked
final. The final turn takes everything pending, whateverturns.onnames, and waits for nowindow. Its outcome must beCOMPLETED, with a reason;OPENfails as invalid output. - Nothing delivered before the end goes unreviewed. A turn is final only if it takes every input pending when it starts. When
maxInputsormaxBytesleave some, it is an ordinary turn, which may only keep the session open, and the next turn continues. A turn that is running when the end passes finishes as an ordinary turn, and then the end applies. - With nothing pending at its end, the session completes with no turn: status
COMPLETED, reasonended. - A session
WAITINGfor an approval response at its end staysWAITINGuntil the response arrives. The turn that takes the response, with everything pending with it, is final. - Inputs delivered while the final turn runs stay
PENDINGwhen the session completes, kept as evidence, as for any closed session.
Without ends, a session completes only when a turn reports COMPLETED or an operator closes it; a session that receives no inputs stays OPEN.
Session clock
session.schedule gives each session its own clock: up to 8 named entries, each a cron expression evaluated in its timezone, or an every period. A cron entry fires at each time its expression matches after the session's startedAt, and an every entry at startedAt + every, startedAt + 2 × every, and so on. No entry fires at or after the session's end. Expressions, timezones, and daylight-saving changes follow the rules of schedule triggers, and a firing comes within about a minute of its time.
Each firing is an input of kind SCHEDULE to that session alone. Its event is a schedule.fired event whose data is {schedule, scheduled_at, missed}: the entry's name, the scheduled time, and how many earlier firings of the entry the turn passed over. Its identity is schedule/<name>/<scheduled_at>.
- A firing starts a turn when
turns.onis omitted or names its entry. Otherwise it waits for the next turn, as an input of a trigger thatondoes not name does. - A firing never waits for a
window: one that starts a turn starts it at once, with every input pending with it. - A turn takes at most one firing of each entry, the latest that is due.
missedcounts the entry's firings it passed over since the last one a turn took, for example while the session wasWAITINGor a turn was running. - Firings are placed among a turn's inputs by their scheduled time, against the commit times of its events.
- A firing is recorded among the session's
inputswhen a turn takes it.
session: {
inputSchema: {
type: "object"
properties: { account_id: { type: "string", format: "uuid" } }
required: ["account_id"]
}
id: "string(session.input.account_id)"
ends: "session.started_at + duration('720h')"
schedule: [
{ name: "morning", cron: "0 9 * * 1-5", timezone: "America/New_York" }
{ name: "hourly", every: "1h" }
]
turns: { on: ["entry", "morning"] }
}
With the triggers of the example above, each session ends 30 days after it starts. An entry input or the weekday morning firing starts a turn; an hourly firing waits for the next turn, which receives only the latest one, with missed counting those it passed over.
Inspecting and closing sessions
| Operation | Behavior |
|---|---|
session(actionId, id, version) | The session, or null. Pass version for an exact historical version. Action.session(id, version) reads the same session from its action. |
sessions(where, sort, first, after) | Sessions of every action ordered by creation time, newest first unless sort: ASC. where takes actionId, sessionId (with actionId), status, createdAfter, and createdBefore. Action.sessions lists one action's. |
closeSession(actionId: UUID!, id: String!, reason: String!) | Mutation. Cancels the session with a retained reason. It accepts no further input, a running turn cannot commit and becomes CANCELLED, and its inputs and turns remain readable. Effects already performed are not undone. Closing a session that is already closed, whether cancelled, completed, or failed, returns it unchanged, with the status and reason it closed with. |
A session reaches everything that happened in it:
| Field | Type | Meaning |
|---|---|---|
id | ID! | Global identifier for the node(id) query. |
actionId, sessionId | UUID!, String! | The owning action and the session ID, unique within it. |
action | Action! | The action version the session is pinned to. |
status, reason, state | Lifecycle, and the handler's state as the latest committed turn left it. | |
startedAt | Timestamp! | Commit time of the session's origin. |
endsAt | Timestamp | When the session ends, from the definition's session.ends; null when it declares none. It moves with startedAt. See Session end. |
turn(number) | ActionSessionTurn | One turn by its number, or null. |
turns(sort, first, after) | ActionSessionTurnConnection! | The session's turns, newest first unless sort: ASC. The first is the latest turn: its number is the session's turn count, and it is RUNNING while the session is busy. |
history(first, after) | ActionSessionConnection! | Every version of the session, newest first: each turn's start and commit, and each status change. |
inputs(first, after) | ActionSessionInputRecordConnection! | Inputs delivered to turns, in delivery order, then PENDING inputs in the order they arrived. Each carries its event and the execution of the turn it was delivered to. A PENDING input has no record yet, so its version and _recordId are null. |
Each input has a kind: EVENT, an event a trigger matched; COMMAND, such as an approval response; CALL, a call to a tool with a session; SCHEDULE, a firing of the session's clock, whose event is a schedule.fired event; or REQUEST, a request to an MCP server, with its execution and the number of tool calls it made in calls.
An input's event reads the ledger record it refers to through record. This query follows one session turn by turn, newest first: what each turn received, the session as it left it, and, from its execution, what it replied and did:
query Session($actionId: UUID!, $id: String!) {
actions {
action(id: $actionId) {
session(id: $id) {
status
state
turns(first: 50) {
nodes {
number
status
inputs {
event {
eventType
record {
... on KVValue {
namespace
key
value
}
}
}
}
checkpoint {
state
status
}
execution {
invocation {
output
transcript(first: 100) {
nodes {
kind
data
}
}
}
children(first: 50) {
nodes {
action {
definition {
name
}
}
status
}
}
}
}
}
}
}
}
}
Approvals
A tool reference of an agent can require approval with approval: REQUIRED. Before each call to that tool, the agent's turn ends and commits with the session WAITING: everything it did before the call commits with it, and its execution's invocation.approvals lists the call it paused before, with the tool, the tool version the call would run, the call's effective input, and the executionId of the execution that first paused before it; toolAction and execution read the tool version and that execution. A request the session still waits on is listed again, under the same interruptId and executionId, by the execution of each later turn that ends with the session WAITING. Other inputs that arrive while the session waits stay PENDING, in the order they arrived, and are delivered once it is OPEN again. A gated tool cannot be called from programmatic tool calling; there the call fails with an error the code can catch, telling the agent to call the tool directly.
Answer a request with respondToSession. The response is delivered to the session as a COMMAND input, in a turn of its own, and the agent continues from where it paused: an approved call runs, with the replacement input if the response supplies one and the reference's fixed input still applied, and a denied call is reported to the agent as denied, with the reason. Each request is answered once. The caller needs the RESPOND policy action on the action's record (financial.Action.document), and the input's event, of type action_session.responded, records the decision, the replacement input, the reason, and the responder.
| Mutation | Arguments | Behavior |
|---|---|---|
respondToSession | actionId: UUID!, id: String!, interruptId: String!, response: ActionSessionResponseInput! | Answers the approval request interruptId of a WAITING session with response.approval: decision: APPROVE or DENY, an optional replacement input for an approval, and an optional reason of at most 1 KiB. Answers a held turn with response.hold instead. Returns the admitted input, PENDING. Fails if the session is not WAITING, the request is not one it waits on, or the request was already answered. |
mutation Approve($actionId: UUID!, $interruptId: String!) {
actions {
respondToSession(
actionId: $actionId
id: "3f0c9f8e-account"
interruptId: $interruptId
response: { approval: { decision: APPROVE, reason: "Confirmed with the customer." } }
) {
identity
kind
status
}
}
}
Held turns
A session turn that the model declines to continue fails with agent_refused. A turn that does not report an outcome is retried, and fails with agent_unfinished once its retries are spent. Either way, nothing the turn did commits: its writes, its calls' writes, and what it said are discarded, and its execution keeps its transcript. Instead of failing the session, the turn is held. Its inputs are received, the session's state stays as the turn before left it, and the session is WAITING with a reason naming the turn. The turn is listed as HELD, and its execution's invocation.hold has the interruptId to answer, the cause, REFUSED or UNFINISHED, and, for a refusal, the category and explanation the model provider reports, if any. Other inputs that arrive while the session holds stay PENDING, and are delivered once it is OPEN again.
Answer a held turn with respondToSession and response.hold:
RETRYruns the held turn's inputs again, in a turn of their own, followed by the answer as aCOMMANDinput. The retry turn'sinputslists only thatCOMMAND; the inputs it runs again stay with the held turn, and its transcript shows the agent receiving them. The answer's optionalnote, at most 1 KiB, is shown to the agent with the inputs. A retry of a turn that resumed from approval responses runs the approved calls again, and the agent sees the note with their results. The turn runs at the session's pinned version, so a change to the agent applies only to sessions started after it.SKIPgoes on without the held turn's inputs, with no model call, and the session isOPENagain. A turn that resumed from approval responses cannot be skipped, because the responses it consumed cannot be given again: retry it, or close the session.
Each hold is answered once, by a caller with the RESPOND policy action, and the answer's action_session.responded event records the decision, the note, and the responder. Closing the session ends it instead. A turn that is held again after a retry is answered the same way, and a retry of it runs the inputs of the turn it retried too.
mutation Retry($actionId: UUID!, $interruptId: String!) {
actions {
respondToSession(
actionId: $actionId
id: "3f0c9f8e-account"
interruptId: $interruptId
response: {
hold: { decision: RETRY, note: "The transfer was reviewed; continue monitoring." }
}
) {
identity
status
}
}
}
Tool sessions
A tool with a session records its calls per session ID. Its tool.session has a start with the same shape as a start trigger's, with expressions over tool.input instead of event. Every call starts its session; a tool session has no resume. A tool with a session has no triggers, turns, ends, or schedule.
A tool session is call history only: it has no state, inbox, or turns, and calls to it never wait for one another. A call finds its session, or creates it before the call runs, so concurrent first calls for one session all succeed; a session whose first call rolls back exists with no history. A call whose session already exists with a different input fails. A call whose session is closed fails with session_closed and runs and records nothing. A call checks the session's status when it starts, so a call that is already running when an operator closes the session still records when its transaction commits: a closed session can gain calls from transactions that were in flight at the close. Each committed call adds one CALL input to its session, with the effective input, the output, and the calling agent execution, if any; the input's identity is the call's executionId. The record commits or rolls back with the caller, so the history lists committed calls only. Calls are listed in the order their transactions began, and calls made in one transaction keep the order they were made in. A transaction lists its own uncommitted calls in that order too. A call made by a transaction that is still running when you read a page can commit later with a position before that page's cursor: continuing from the cursor does not return it, and a new listing does.
A Starlark tool reads its session as event["session"], with id and input. It can read the calls before it through its session's inputs, which include the calls made earlier in the same transaction. session(actionId, id) finds the session a call records in even when the call's transaction began before the session was created. The session stays OPEN until an operator closes it.
tool: {
name: "notify_human_ops"
description: "Notify human operations about a finding."
inputSchema: {
type: "object"
properties: { account_id: { type: "string" }, note: { type: "string" } }
required: ["account_id"]
}
session: { start: { input: { account_id: "tool.input.account_id" } } }
}