Agent
This page lists the Agent authoring API and the HelmrClient methods that
operate Sessions and Turns. For what Agents, Sessions and Turns are and how
they behave, see Agents,
Sessions and Turns.
agent()
agent(config) infers the Agent ID, initialization state, validated input and
result types. Prefer inference over explicit generic arguments. inputSchema
and resultSchema accept Standard Schema v1 validators, including asynchronous
validators and transforms.
Returns a frozen definition with kind: "agent". Throws TypeError when id
is blank, template is not a Template definition, or turn is not a function.
Export the definition from a file in a discovered source directory (agents by
default; see Configuration).
import { agent, template, image } from "@helmr/sdk"
const repoComputer = template({
id: "repo",
image: image("repo").from("node:24-bookworm-slim"),
resources: { cpu: 1, memory: "1GiB" },
})
export const reviewer = agent({
id: "reviewer",
template: repoComputer,
async init(ctx) {
return { startedAt: Date.now() }
},
async turn(turn, ctx) {
await turn.output.write("Review started\n")
await turn.respond("Review complete")
return { reviewed: true }
},
})
AgentDefinition
AgentDefinition retains the inferred invocation and result contract. Use
typeof yourAgent with typed client calls; do not duplicate its types manually.
When init is absent, ctx.initResult is undefined.
| Field | Required | Description |
|---|---|---|
id |
yes | Definition ID, matching ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$. Used by client.sessions.create({ agent: id }) and helmr session create. |
template |
yes | A template() definition. New Sessions run on a Computer created from it unless the start request names an existing Computer. See Computer. |
runtimeMcp |
no | Runtime MCP maximum: { allow: [...] } declares permitted operations and Agent keys. Omission resolves to that Deployment’s Agent set with new Computer placement; { allow: [] } grants no remote business operations. See Runtime MCP policy. |
init |
no | Runs once per Session process before its first Turn. The returned value is ctx.initResult. |
turn |
yes | Handles one Turn. Its return value is validated by resultSchema when supplied and becomes the machine result. |
inputSchema |
no | Validates incoming JSON before the handler; turn.input is the schema output. Without it, input is conversational text content. |
resultSchema |
no | Validates or transforms the handler return value before successful settlement. The persisted result must be finite JSON. |
closeAfterIdle |
no | Closes a Session after it stays idle this long: a Duration or "never". Default "7d". |
hibernateAfterIdle |
no | Hibernates the Session’s Computer after the Session stays idle this long: a Duration or "never". Default "never". Background work on the Computer pauses until it wakes. See Durability. |
maxTurnDuration |
no | A Duration after which a running Turn reaches its deadline. No limit when omitted. |
triggers |
no | Cron triggers. See Triggers. |
Both periods belong to the Agent definition. A Session uses the values of the Deployment it is pinned to; a later Deployment changes them only for new Sessions. To use different values, deploy a different Agent.
Typed invocation
import type { worker } from "./agents/worker"
const started = await client.sessions.create<typeof worker>({
agent: "worker",
input: { issue: 42 }, // checked against this Agent's schema input
})
const outcome = await started.turn.wait()
if (outcome.status === "completed" && outcome.payloadExpiredAt === undefined) {
console.log(outcome.result) // inferred persisted result type
}
The example assumes worker accepts { issue: number }. Type-only imports do
not load its tools or credentials into the caller. AgentInput<typeof worker>
and AgentResult<typeof worker> expose the inferred types. A create call with
supplied non-undefined input returns a Turn; an omitted input returns turn: null.
These types require the imported definition to match the deployed code. To attach
to an existing Session, use client.sessions.attach<typeof worker>({ id, agent: "worker", deploymentId }); it checks the actual Agent and Deployment identity.
A dynamic ref(id) retains JSON types and does not claim a checked contract.
Input validation follows Session initialization. Rejected input can therefore
have incurred initialization work. Invalid input settles as input_invalid;
invalid results settle as result_invalid and never publish a staged response.
Runtime schema validation is authoritative. Optional JSON Schema discovery is
advisory and can be unavailable for validators that do not provide conversion.
Structured-input Agents use a conversational front for Slack; child results are
not automatically posted as human text.
Turn
interface Turn<I = InputContent> {
readonly id: string
readonly sequence: number
readonly createdAt: string
readonly input: I
readonly source: InputSource
readonly signal: AbortSignal
onCancel(handler: (reason: unknown) => MaybePromise<void>): void
readonly output: {
write(value: HumanContent): Promise<OutputReceipt>
pipe(values: AsyncIterable<HumanContent>, options?: { signal?: AbortSignal }): Promise<void>
}
respond(value: HumanContent): Promise<void>
onMessage(handler: (message: Message) => MaybePromise<void>): Promise<void>
ask<const Q extends Question>(
question: Q,
options?: { signal?: AbortSignal },
): Promise<AskAnswer<AnswerFor<Q>>>
}
| Member | Description |
|---|---|
id, sequence, createdAt |
Turn ID, admission sequence within the Session, and creation time. |
input |
Validated schema output, or conversational text parts when no input schema is declared. |
source |
Where the input came from. See InputSource. |
signal |
Aborts when this Turn is cancelled, interrupted, or fails. |
onCancel(handler) |
Registers asynchronous cancellation work that the runtime joins alongside the handler. |
output.write(value) |
Appends Content (or a string) to the Turn’s output. Resolves to { sequence }. |
output.pipe(values, { signal? }) |
Appends each item of an async iterable in order. Resolves when the iterable is exhausted. |
respond(value) |
Stages the Turn’s human-facing response. Call at most once; a second call rejects with response_already_staged. |
onMessage(handler) |
Registers a handler for messages sent to this exact Turn. Await the registration. Messages are delivered one at a time. |
ask(question, { signal? }) |
Asks a person a question and resolves with the answer. Root Sessions only. See Questions. |
Await every operation you start before the handler returns. New responses and questions are not admitted after the handler returns.
Content rules
| Value | Shape | String shorthand |
|---|---|---|
turn.input without an input schema; live message input |
InputContent: readonly { type: "text"; text: string }[] |
no |
output.write, output.pipe items, respond |
HumanContent: Content | string |
yes |
| Question prompt | HumanContent: Content | string |
yes |
Content is readonly ContentPart[], where a part is
{ type: "text", text } or { type: "json", value }. Adjacent text fragments
in output concatenate exactly, without inserted separators; JSON parts are
atomic. respond([]) or respond("") records an explicit empty response.
| Limit | Value |
|---|---|
| One Content value or question | 256 KiB of encoded JSON, 64 parts |
| Output plus admitted questions, per Turn | 8 MiB cumulative |
| Response | 256 KiB, separate from the output budget |
File parts are not supported. A path or URL inside JSON does not transfer file bytes.
InputSource
type InputSource =
| { kind: "user"; slack?: SlackInputSource }
| { kind: "api_key" }
| { kind: "schedule" }
| { kind: "session"; requesterSessionId: string; originTurnId?: string }
type MessageSource = Exclude<InputSource, { kind: "schedule" }>
interface Message {
readonly id: string
readonly content: InputContent
readonly source: MessageSource
}
Narrow source.kind before reading caller-specific fields. Session-originated
input includes requesterSessionId; originTurnId is present when it was
created during a Turn. Slack input belongs to a user source and includes
teamId, channelId, threadTs, messageTs and userId.
Each onMessage callback receives one Message envelope with its own content,
identity and source. Schedules create Turns and are not live message sources.
Cancellation and native SDKs
Await initialization in init and invocation work in turn. Ordinary Node child
processes, including children created during a Turn, belong to the Session and
can remain alive between Turns. Join the invocation and callbacks according to
the native SDK’s documented completion contract on both success and failure.
Request acknowledgement, native Turn completion and server shutdown can be
different events. None implies universal cessation of arbitrary OS descendants.
Use turn.signal with signal-aware APIs. For an asynchronous interruption API,
register turn.onCancel(reason => ...). The runtime starts registered callbacks
concurrently with handler drainage on interruption, deadline, Session shutdown or
uncaught handler failure, and joins them before acknowledging drainage. Each
registration runs at most once in that runtime execution. Successful completion
does not run cancellation callbacks. A callback registered after cancellation
starts immediately while drainage is still open; registration after closure throws.
The handler and callbacks share a bounded cleanup period, currently 10 seconds in the managed runtime. A rejected cancellation callback or expired cleanup period prevents Session reuse and requires explicit recovery; the platform terminates the entire Session process tree. A fully drained ordinary handler failure can preserve a healthy Session. Retain native connection errors, register cancellation before checking native health, and reject a new invocation before submitting work when that connection is broken.
For local HTTP services, the managed runtime removes default response-header and body inactivity timeouts on loopback requests through the global dispatcher. Connection establishment remains bounded. Caller AbortSignals and explicitly provided dispatchers retain their behavior. In-flight HTTP still prevents idle capture; ordinary idle connection expiry allows capture afterwards.
InitContext and AgentContext
interface InitContext {
readonly mcp: Readonly<{ getConnectionOptions(policy?: RuntimeMcpPolicy): Promise<RuntimeMcpConnectionOptions> }>
readonly session: Readonly<{ id: string; key?: string; parent?: Readonly<{ id: string }> }>
readonly computer: ComputerIdentity // { id: string }
readonly deployment: Readonly<{ id: string }>
readonly recovery: Readonly<{ kind: "initial" | "reconstructed"; reason?: string }>
readonly signal: AbortSignal
}
interface AgentContext<S> extends InitContext {
readonly initResult: S
}
| Member | Description |
|---|---|
session.id, session.key, session.parent |
Session ID, optional Session key, and the parent Session for owned child work. |
computer.id |
The Computer this Session runs on. |
deployment.id |
The Deployment the Session is pinned to. |
recovery.kind |
"initial" for a new Session process; "reconstructed" when the Session was explicitly reconstructed and init runs again. reason is optional. |
signal |
Aborts on Session process shutdown, including during init. |
mcp.getConnectionOptions(policy?) |
Returns a scoped local MCP URL and headers. Omission inherits the Agent maximum; a supplied allowlist must be a subset. Available in init and turn, including when init is omitted. See Runtime MCP. |
initResult |
The value returned by init (AgentContext only). |
init does not rerun when an idle Session resumes from hibernation. See
Durability.
Runtime MCP policy
export type RuntimeMcpTargetOperation =
| "enqueue" | "send_turn" | "inspect_session" | "inspect_turn"
| "interrupt_turn" | "interrupt_session" | "resume_session"
| "close_session" | "cancel_session"
export type RuntimeMcpRule =
| {
readonly operation: "start" | "spawn"
readonly agents: readonly string[]
readonly computer?: "new" | "caller"
}
| {
readonly operation: RuntimeMcpTargetOperation
readonly agents: readonly string[]
readonly computer?: never
}
| {
readonly operation: "list_computers"
readonly agents?: never
readonly computer?: never
}
export interface RuntimeMcpPolicy {
readonly allow: readonly RuntimeMcpRule[]
}
The optional agent({ runtimeMcp }) field sets a maximum for the pinned Session.
An explicit policy requires allow; null, booleans, empty objects, unknown fields
and blockAll are invalid. Omission resolves at publication to supported
operations and exact Agent keys in that Deployment, with new Computer placement.
Later publications cannot widen an existing Session’s pin. An explicit policy
grants only its named operations and Agent keys; an empty array grants none.
Policies never inject an MCP connection automatically.
At most 256 rules are accepted. Agent-targeted rules require 1–256 unique exact
Agent keys in the same published manifest. Creation rules default to
computer: "new"; "caller" permits reuse of the caller’s logical Computer.
Use two rules to permit both. Other targeted operations forbid computer;
list_computers forbids both agents and computer. Overlapping rules union
their grants, with deterministic normalization. Rule order has no precedence.
Use the canonical operation names above; observation aliases such as wait_turn
are derived from inspect_turn rather than authored as extra grants.
ctx.mcp.getConnectionOptions() inherits the maximum, including a finite or
empty maximum. getConnectionOptions({ allow: [...] }) obtains a narrower
descriptor and rejects widening. Getter Agent keys are checked against the
caller’s pinned manifest and published maximum. Equivalent
normalized policies reuse the same connection credentials; a Session process
supports at most 16 distinct scopes, including its maximum, without eviction.
Existing Role, origin, relationship, Environment and lifecycle checks still
apply. Unrestricted MCP retains eligible existing-Computer placement; finite
creation rules limit placement to new or caller. Creation adds no implicit
inspection or control grant. Native integration, process continuity and security
boundaries are covered in Execution policies
and the Runtime MCP reference.
MessageRejected
class MessageRejected extends Error {
constructor(message: string)
}
Throw MessageRejected from an onMessage handler to reject that message
before the handler has caused any effects. The Session records a
message.rejected event with reason message_rejected and delivery
not_delivered, and the Turn continues. Any other error thrown from a message
handler is recorded with reason callback_failed and fails the Turn.
instanceof MessageRejected works across copies of the SDK.
Creating other work
Agent code creates and controls other Sessions through the runtime MCP tools, not through an SDK method. See Runtime MCP and Delegation.
Client: create a Session
client.sessions.create(request: SessionCreateRequest, options?: RequestOptions): Promise<SessionCreateReceipt>
interface SessionCreateRequest<A extends AgentContract = AgentContract<string, JsonValue, JsonValue>> {
readonly agent: A["id"]
readonly input?: AgentInput<A>
readonly slack?: Readonly<{ channelId: string }>
readonly computer?: Readonly<{ id: string }>
readonly key?: string
readonly idempotencyKey?: string
}
interface SessionCreateReceipt {
readonly session: SessionRef
readonly turn: TurnRef | null
}
| Option | Description |
|---|---|
agent |
Authored Agent ID in the current Deployment. |
input |
Optional initial Turn input. Omit for zero Turns; supplied input must be finite JSON and match the Agent contract. |
slack.channelId |
Opens the Session in a Slack channel, by raw channel ID (for example C0123456789). See Slack. |
computer.id |
Runs on an existing Computer. Omit to assign one from the pinned Template definition on first input. |
key |
Unique Session identity within its Agent. An occupied key conflicts. |
idempotencyKey |
Retry identity. When omitted, the SDK generates a new key for each call, so pass one explicitly to retry safely. |
New Sessions pin the current Deployment. Enqueue later work using the Session ID. Exact creation retries retain the original receipt, including a null initial Turn. The receipt confirms admission, not completion.
Client: Sessions
client.sessions.get(id: string, options?): Promise<Session>
client.sessions.list(query?: SessionListQuery, options?): Promise<CursorPage<Session>>
client.sessions.ref(id: string): SessionRef
ref() builds a local handle without a network request.
SessionListQuery is one of:
| Form | Fields |
|---|---|
| Page | parentSessionId?, requesterSessionId?, status? (one SessionStatus or an array), cursor?, limit? (1–100) |
| Agent filter | agent (declared identifier); current membership is not required |
| Exact key lookup | One Agent selector plus the Session key; no other filters or pagination |
SessionRef
| Method | Returns | Description |
|---|---|---|
get(options?) |
Session |
Current Session state. |
enqueue(input, { idempotencyKey? }?, options?) |
TurnRef |
Always admits a new Turn. |
turns.ref(id) |
TurnRef |
Local handle for a Turn. |
turns.list({ cursor?, limit? }?, options?) |
CursorPage<TurnState> |
Turns in admission order; limit 1–100. |
events.list({ after?, limit? }?, options?) |
SessionEventPage |
One page of events. after defaults to 0; limit defaults to 100, maximum 1000. |
events.stream({ after? }?, options?) |
AsyncIterableIterator<SessionEvent> |
Follows pages from after, polling when caught up. Stop it with signal. |
interrupt({ idempotencyKey? }?, options?) |
SessionInterruptReceipt |
Holds the Session and its owned descendants. Receipt includes holdId. |
resume({ holdId, idempotencyKey? }, options?) |
SessionResumeReceipt |
Releases the exact hold. |
close({ idempotencyKey? }?, options?) |
SessionCloseReceipt |
Stops new admission and drains work that can run; held queued Turns and pending questions are cancelled. |
cancel({ idempotencyKey? }?, options?) |
SessionCancelReceipt |
Cancels queued and active work in the owned tree and closes it. |
Interrupt and resume receipts have { id, sessionId, status: "accepted", holdId }.
Close and cancel receipts have { sessionId, status }, the Session status after
the request: closing or closed for close, closed for cancel. Closing or
cancelling a closed Session returns its status and changes nothing. Receipts do
not confirm that execution has stopped.
For control semantics see Sessions. Event kinds and
payloads are listed in Session events.
Session
| Field | Type |
|---|---|
id, deploymentId, rootSessionId |
string UUIDs |
agent, template |
Authored Agent identifier and its pinned default Template identifier |
computerId, parentSessionId, requesterSessionId |
string | null |
key |
string, optional |
slackChannelId |
string, optional |
status |
"open" | "closing" | "closed" |
closeAfterIdle |
Retention (Duration or "never"), from the pinned Agent definition |
idleSince |
string | null |
initialTurn |
{ id, status } | null |
holds |
{ id, sessionId, kind: "interrupt" | "process_failed" | "recovery_gap" | "storage_pressure", recoveryGap?, createdAt, canRelease }[]. id and sessionId are null for a hold on an unreadable ancestor. Resume a hold on its sessionId. |
createdAt |
string |
TurnRef
| Member | Returns | Description |
|---|---|---|
id, sessionId |
string |
Identity. |
get(options?) |
TurnState |
Current Turn state. |
wait({ signal? }?) |
TurnOutcome |
Resolves when the Turn settles. |
wait({ timeout, signal? }) |
TimedTurnWaitResult |
{ status: "timeout" } or { status: "settled", outcome }. A timeout never cancels the Turn. |
send(message, { idempotencyKey? }?, options?) |
MessageReceipt |
Sends a message to exactly this Turn. Returns { id, status: "accepted" }. |
interrupt({ idempotencyKey? }?, options?) |
TurnInterruptReceipt |
Interrupts this running Turn without a hold. Returns { id, sessionId, turnId, status: "accepted" }. |
asks |
TurnAsks |
Question methods. See Questions. |
TurnState has id, sessionId, sequence, status, and optional input,
startedAt, terminalAt, result, response, error
({ code, message? }) and payloadExpiredAt. TurnStatus is one of queued,
running, completed, failed, interrupted, cancelled.
TurnOutcome is discriminated by status. A completed outcome with retained
payload has its typed result and optional human response; an expired payload
has payloadExpiredAt and no result or response. Failed outcomes may contain an
error; interrupted and cancelled outcomes never contain a result or response.
Narrow status and retention before reading payload fields. Timed waits use the
separate timeout / settled envelope shown above.
Client: Agents
client.agents.get(id: string, query?: { deploymentId?: string }, options?): Promise<AgentInfo>
client.agents.list(query?: { deploymentId?: string; cursor?: string; limit?: number }, options?): Promise<AgentPage>
AgentInfo includes agent, template, deploymentId, the input contract and
runtimeMcp, a resolved normalized allowlist, preserving { allow: [] }.
An omitted authored policy resolves to the published Deployment’s exact Agent
set, with new Computer placement. Creation rules
include their resolved computer placement. List items expose the same policy.
The agent identifier is declared in code and remains stable across Deployments
within its environment. inputKind: "text" identifies conversational input;
inputKind: "json" includes inputSchema, which is either an available JSON
Schema or an explicit unavailability reason. This discovery does not replace
runtime validation.
AgentPage is { deploymentId, items, nextCursor? }; each item includes
agent, template and the same input contract.
Omitting deploymentId reads the current Deployment; a list cursor keeps its
original Deployment.