# State, hooks, and session context

> defineState get/update persistence, defineHook stream subscribers, ctx.session/getSandbox/getSkill/getToken/requireAuth, and where managed-context APIs are valid.

- Repository: vercel/eve
- GitHub: https://github.com/vercel/eve
- Human docs: https://grok-wiki.com/public/docs/vercel-eve-759e1d74a10f
- Complete Markdown: https://grok-wiki.com/public/docs/vercel-eve-759e1d74a10f/llms-full.txt

## Source Files

- `docs/guides/state.md`
- `docs/guides/hooks.md`
- `docs/guides/session-context.md`
- `packages/eve/src/public/context/index.ts`
- `packages/eve/src/public/definitions/hook.ts`
- `packages/eve/src/compiler/normalize-hook.ts`

---

---
title: "State, hooks, and session context"
description: "defineState get/update persistence, defineHook stream subscribers, ctx.session/getSandbox/getSkill/getToken/requireAuth, and where managed-context APIs are valid."
---

Eve runs authored runtime code inside an AsyncLocalStorage-scoped context container. `defineState`, `defineHook`, and `ctx.*` accessors resolve against that container and throw with `No active Eve context` when called outside a managed execution scope. Durable values—including author-defined state slots—serialize at workflow step boundaries and survive crashes, redeploys, and multi-day sessions.

## Durable state with `defineState`

Import `defineState` from `eve/context`. Declare handles at module scope so every importer shares the same slot.

```ts title="agent/lib/budget.ts"
import { defineState } from "eve/context";

export const budget = defineState("my-agent.budget", () => ({ count: 0, cap: 25 }));
```

<ParamField body="name" type="string" required>
Stable string identifier. Namespace to your agent (for example `myapp.budget`). Must not start with the reserved `eve.` prefix — that prefix is reserved for framework context keys and collisions silently corrupt serialization.
</ParamField>

<ParamField body="initial" type="() => T" required>
Function that produces the starting value on first access within a context.
</ParamField>

`defineState` returns a `StateHandle<T>`:

<ResponseField name="get()" type="T">
Reads the current value. Returns `initial()` on first access within the active context.
</ResponseField>

<ResponseField name="update(fn)" type="void">
Replaces the value with `fn(current)`.
</ResponseField>

Both methods require an active Eve context. Use them from tools, hooks, channel handlers, and other framework-invoked callbacks:

```ts title="agent/tools/spend.ts"
import { defineTool } from "eve/tools";
import { z } from "zod";
import { budget } from "../lib/budget.js";

export default defineTool({
  description: "Run a query, counting it against the session budget.",
  inputSchema: z.object({ sql: z.string() }),
  async execute({ sql }) {
    const { count, cap } = budget.get();
    if (count >= cap) throw new Error("Query budget exhausted for this session.");
    budget.update((s) => ({ ...s, count: s.count + 1 }));
    return runQuery(sql);
  },
});
```

### Persistence model

Author-defined state slots register as durable `ContextKey` entries. At each step boundary the runtime calls `serializeContext`, persisting every durable key in the active container into the workflow checkpoint. On resume, `deserializeContext` hydrates a fresh container from the checkpoint.

<Note>
Values must be JSON-safe. Author-defined keys have no custom codec; the serializer stores them as-is.
</Note>

State does not reset between turns by default. To clear per turn, overwrite from a lifecycle hook:

```ts title="agent/hooks/reset-budget.ts"
import { defineHook } from "eve/hooks";
import { budget } from "../lib/budget.js";

export default defineHook({
  events: {
    async "turn.started"() {
      budget.update(() => ({ count: 0, cap: 25 }));
    },
  },
});
```

### Subagent isolation

`defineState` values never cross the parent/child boundary. Every subagent—built-in `agent` copy or declared specialist—starts with fresh state, even when it is a copy of the same agent definition.

### State vs external storage

| Concern | `defineState` | External store / connection |
| --- | --- | --- |
| Scope | One session | Cross-session, cross-user |
| Lifetime | Dies with the session | Independent of conversation |
| Use case | Running counters, in-conversation plans, glossaries | Durable records, shared data, queryable tables |

## Stream hooks with `defineHook`

Hooks subscribe to the runtime NDJSON event stream from `agent/hooks/`. They run observe-only side effects after each event is durably recorded.

```ts title="agent/hooks/audit.ts"
import { defineHook } from "eve/hooks";

export default defineHook({
  events: {
    async "session.started"(_event, ctx) {
      console.info("session started", { sessionId: ctx.session.id });
    },
    async "message.completed"(event) {
      console.info("model finished", { length: event.data.message?.length ?? 0 });
    },
  },
});
```

Import `defineHook`, `HookDefinition`, `HookContext`, and related types from `eve/hooks`.

### Path-derived slug

The hook slug is the path-relative basename under `agent/hooks/`:

| File | Slug |
| --- | --- |
| `agent/hooks/audit.ts` | `audit` |
| `agent/hooks/auth/load-profile.ts` | `auth/load-profile` |

Discovery preserves lexicographic slug ordering when building the runtime registry.

### Event subscribers

The `events` map keys handlers by stream event type. Use `*` to match every event.

| Event type | Typical use |
| --- | --- |
| `session.started` | Session bootstrap, audit |
| `turn.started` / `turn.completed` | Per-turn resets, metrics |
| `message.completed` | Model output logging |
| `action.result` | Tool result inspection |
| `step.started` / `step.completed` / `step.failed` | Step-level tracing |
| `turn.failed` / `session.failed` / `session.completed` | Failure and completion handling |
| `session.waiting` | Parked-session notifications |

Handlers are observe-only. They cannot inject model context. To contribute runtime model messages, use `defineDynamic` and `defineInstructions` under `agent/instructions/`.

### `HookContext`

Every handler receives `(event, ctx)` where `ctx` is the last argument. `HookContext` extends `SessionContext` with agent and channel metadata:

```ts
interface HookContext {
  readonly agent: { readonly name: string; readonly nodeId?: string };
  readonly channel: { readonly kind?: string; readonly continuationToken?: string };
  readonly session: { readonly id: string; /* + auth, turn, parent */ };
  getSandbox(): Promise<SandboxSession>;
  getSkill(identifier: string): SkillHandle;
}
```

### Execution order

When a stream event fires:

```mermaid
sequenceDiagram
  participant Adapter as Channel adapter
  participant Stream as Durable stream
  participant Hooks as Hook registry
  participant Dynamic as Dynamic resolvers

  Adapter->>Stream: emit (adapter handler, then write)
  Stream->>Hooks: typed handlers for event.type
  Hooks->>Hooks: wildcard (*) handlers
  Hooks->>Dynamic: dynamic tool/skill/instruction resolvers
```

1. **Emit** — the channel adapter handler runs, then the event is written to the durable stream.
2. **Hooks** — typed handlers for the event type run first, then `*` wildcard handlers. Return values are ignored.
3. **Dynamic resolvers** — tool, skill, and instruction resolvers subscribed to the event type update the active capability set.

Hooks always run after durable recording, so a thrown handler does not roll back the stream.

### Failure behavior

A thrown hook handler propagates through the emit composer and surfaces as `turn.failed`. If a hook subscribed to a failure-cascade event also throws, the failure escalates to `session.failed`. Wrap hook bodies in `try`/`catch` when you need belt-and-suspenders semantics.

### Narrowing tool results

Import `toolResultFrom` from `eve/tools` to narrow `action.result` events to a specific authored tool or MCP connection:

```ts
import { toolResultFrom } from "eve/tools";
import getWeather from "../tools/get-weather";
import linear from "../connections/linear";

"action.result"(event) {
  const weather = toolResultFrom(event.data.result, getWeather);
  if (weather) console.log(weather.output.temperature);

  const linearResult = toolResultFrom(event.data.result, linear);
  if (linearResult) console.log(linearResult.connectionToolName, linearResult.output);
}
```

Returns `undefined` when the result does not match or when `isError` is `true`.

### Subagent hook isolation

Subagents may carry their own `agent/hooks/` directory. Subagent hooks fire only inside the subagent scope. Parent hooks do not fire for subagent turns, and subagent hooks see only the subagent's own context.

## Session context (`ctx`)

`ctx` is the shared runtime surface passed to tool `execute`, hook handlers, and channel event handlers. Tools with a declared `auth` strategy receive `ToolContext`, which extends `SessionContext` with token accessors.

### `ctx.session`

Read-only durable metadata for the active execution:

| Field | Type | Description |
| --- | --- | --- |
| `id` | `string` | Active session identifier |
| `turn.id` | `string` | Current turn identifier |
| `turn.sequence` | `number` | Turn sequence within the session |
| `auth.current` | `SessionAuthContext \| null` | Caller for the active inbound turn |
| `auth.initiator` | `SessionAuthContext \| null` | Caller that started the durable session |
| `parent` | `SessionParent \| undefined` | Present for child subagent sessions |

`SessionParent` includes `callId`, `sessionId`, `rootSessionId`, and `turn` (the immediate parent's turn metadata).

Behavior notes:

- Unprotected agents expose both `auth.current` and `auth.initiator` as `null`.
- Top-level schedule sessions expose the framework app principal (`principalId: "eve:app"`, `principalType: "runtime"`).
- `parent` is present only for subagent child sessions.

```ts title="agent/tools/who_called_me.ts"
async execute(_input, ctx) {
  return {
    sessionId: ctx.session.id,
    turnId: ctx.session.turn.id,
    currentCaller: ctx.session.auth.current?.principalId,
    initiator: ctx.session.auth.initiator?.principalId,
    parentSessionId: ctx.session.parent?.sessionId,
    parentCallId: ctx.session.parent?.callId,
  };
}
```

### `ctx.getSandbox()`

Returns a live `SandboxSession` handle for the current agent's sandbox.

- Async — Eve binds or restores sandbox state lazily.
- Takes no arguments; each agent has exactly one sandbox.
- Node-local visibility — a subagent sees its own sandbox, not the parent's.
- Throws when sandbox access is not attached to the active runtime path.

```ts
const sandbox = await ctx.getSandbox();
const result = await sandbox.run({ command: "npm test" });
```

`SandboxSession.resolvePath(path)` returns the backend-native path for a logical `/workspace/...` location.

### `ctx.getSkill(identifier)`

Returns a synchronous `SkillHandle` for a path-derived skill id visible to the current agent.

```ts
const skill = ctx.getSkill("research");
const notes = await skill.file("references/checklist.md").text();
```

- File content reads lazily from the active sandbox.
- Requires sandbox access on the active runtime path.
- A missing skill surfaces when a file accessor reads a missing sandbox path.
- The handle exposes `name` and `file(relativePath)`.

## Tool authorization: `getToken` and `requireAuth`

`getToken()` and `requireAuth()` exist only on `ToolContext` — the `ctx` passed to `defineTool(...).execute`. They are meaningful only when the tool declares an `auth` strategy.

```ts title="agent/tools/fetch-data.ts"
import { defineTool } from "eve/tools";
import { connect } from "@vercel/connect/eve";

export default defineTool({
  description: "Fetch data from an authenticated API.",
  inputSchema: z.object({ id: z.string() }),
  auth: connect("my-api"),
  async execute({ id }, ctx) {
    const { token } = await ctx.getToken();
    return fetch(`https://api.example.com/${id}`, {
      headers: { Authorization: `Bearer ${token}` },
    }).then((r) => r.json());
  },
});
```

<ParamField body="auth" type="ToolAuthDefinition">
Optional on `defineTool`. Accepts the same shapes as connection `auth`: a `getToken`-only object (static API keys, pre-provisioned JWTs; `principalType` defaults to `"app"`), or a full interactive OAuth definition (`connect("...")` from `@vercel/connect/eve`, or `defineInteractiveAuthorization`).
</ParamField>

<ResponseField name="getToken()" type="Promise<TokenResult>">
Resolves the bearer token for the tool's declared `auth`, consulting the per-step token cache before invoking the authored `getToken`. For interactive strategies, a cache miss throws `ConnectionAuthorizationRequiredError`; the runtime parks the turn on a framework-owned callback URL and re-runs the tool after OAuth completes.
</ResponseField>

<ResponseField name="requireAuth()" type="never">
Throws `ConnectionAuthorizationRequiredError` to gate execution on authorization without resolving a token first. The runtime converts the error into a consent prompt for interactive strategies.
</ResponseField>

<Warning>
Calling `ctx.getToken()` or `ctx.requireAuth()` on a tool without an `auth` field throws immediately.
</Warning>

The authorization machinery mirrors connection flows: per-step token caching, park/resume on interactive OAuth, loop-guard on tokens rejected immediately after sign-in, and token eviction on downstream 401s.

## Where managed-context APIs are valid

All `defineState` operations, `ctx` accessors, and ALS-backed context reads require an active harness step (`contextStorage.run`).

| Surface | `defineState` / `ctx` | Notes |
| --- | --- | --- |
| `defineTool(...).execute(input, ctx)` | Valid | `ToolContext` when `auth` is declared |
| `defineHook` event handlers | Valid | `HookContext` |
| Channel adapter event handlers | Valid | `SessionContext` |
| `defineDynamic` resolver callbacks | Valid | Framework builds `SessionContext` internally |
| Sandbox `onSession` hook | Valid | Receives `{ ctx, use }`; runs inside ALS |
| Async boundaries within the same authored callback chain | Valid | ALS propagates across `await` |
| Module top-level evaluation | **Throws** | No active context at import time |
| Build scripts / discovery-time code | **Throws** | Outside runtime execution |
| Schedule `run` handler | **No `ctx`** | Receives `ScheduleHandlerArgs` (`receive`, `waitUntil`, `appAuth`) |
| Sandbox `bootstrap` hook | **No `ctx`** | Receives `{ use }` only; runs at build/prewarm |
| Instrumentation `setup` | **No `ctx`** | Domain-specific arguments |

<Info>
Sandbox `onSession` is an exception to the "non-runtime callback" pattern: it receives `ctx: SessionContext` and executes inside the active Eve context, so `defineState` and `ctx.getSandbox()` work there. Sandbox `bootstrap` does not receive session context.
</Info>

Calling any managed API outside an active scope throws immediately:

```
No active Eve context. Call this function only from authored runtime code such as tools, steps, and model callbacks.
```

## Context lifecycle

```text
Step start
  ├─ Deserialize durable context (session id, auth, author state, bundle, …)
  ├─ Rebuild virtual/step-local values (session projection, sandbox access, skills)
  ├─ Enter ALS scope (contextStorage.run)
  │    └─ Authored code: ctx.*, defineState.get/update
  └─ Step end
       ├─ Serialize durable context → workflow checkpoint
       └─ Clear virtual context
```

Seed keys (`eve.sessionId`, `eve.auth`, author `defineState` slots, …) persist across step boundaries. Derived keys (`eve.session`, `eve.sandbox`) are rebuilt each step by context providers and are not serialized directly.

## Hook vs tool vs dynamic capability

| Need | Use |
| --- | --- |
| Observe runtime events (audit, metrics, alerting) | `defineHook` `events.<type>` |
| Provide structured input to the model on demand | `defineTool` |
| Change model context based on stream events | `defineDynamic` under `agent/instructions/` |
| Subscribe to platform-specific ingress | Channel adapter handler |
| Durable per-session working memory | `defineState` |

Stream-event hooks and channel adapter event handlers are structurally similar. Choose the adapter handler for platform-specific behavior; choose `events.*` for agent-level behavior that fires across every channel.

## Related pages

<CardGroup>
  <Card title="Execution model and durability" href="/execution-model">
    Session/turn/step nesting, workflow checkpoints, and crash-resume semantics that make defineState durable.
  </Card>
  <Card title="Sessions and streaming" href="/sessions-and-streaming">
    NDJSON stream event vocabulary that defineHook subscribers observe.
  </Card>
  <Card title="Tools" href="/tools">
    defineTool execute(ctx), auth strategies, and requireAuth flows.
  </Card>
  <Card title="Context control" href="/context-control">
    defineDynamic resolvers that run after hooks on the same stream events.
  </Card>
  <Card title="Subagents and schedules" href="/subagents-and-schedules">
    Subagent state and hook isolation; schedule run handlers without ctx.
  </Card>
  <Card title="TypeScript API reference" href="/typescript-api">
    Full define* import map and exported context types.
  </Card>
</CardGroup>
