# Agent

URL: https://helmr.dev/docs/reference/sdk/agent
Description: agent(), AgentDefinition, the Turn handler API, initialization context, and client Session and Turn methods.

# 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](/docs/concepts/agents),
[Sessions](/docs/concepts/sessions) and [Turns](/docs/concepts/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](/docs/reference/config)).

```ts
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](/docs/reference/sdk/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](#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](/docs/concepts/durability#hibernation). |
| `maxTurnDuration` | no | A `Duration` after which a running Turn reaches its deadline. No limit when omitted. |
| `triggers` | no | Cron triggers. See [Triggers](/docs/reference/sdk/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

```ts
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`

```ts
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`](#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](/docs/reference/sdk/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`

```ts
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`

```ts
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](/docs/reference/sdk/mcp). |
| `initResult` | The value returned by `init` (`AgentContext` only). |

`init` does not rerun when an idle Session resumes from hibernation. See
[Durability](/docs/concepts/durability).

## Runtime MCP policy

```ts
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](/docs/concepts/execution-policies)
and the [Runtime MCP reference](/docs/reference/sdk/mcp).

## `MessageRejected`

```ts
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](/docs/reference/sdk/mcp) and
[Delegation](/docs/concepts/delegation).

## Client: create a Session

```ts
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](/docs/concepts/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

```ts
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](/docs/concepts/sessions). Event kinds and
payloads are listed in [Session events](/docs/reference/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](/docs/reference/sdk/questions#client-methods). |

`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

```ts
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.
