---
title: "Session Loop · Finch Agent"
description: "Creating sessions, sending messages, getting results, and multi-agent orchestration"
source: https://finchwork.app/en/docs/minitools-sessions
---

# Session Loop

`ctx.sessions` lets a mini tool create and drive its own sessions. Typical uses: external platform bots, background task processing, multi-agent orchestration.

**Isolation boundary: a mini tool can only access sessions it created itself** — it can't read the user's regular conversations, and it can't touch another mini tool's sessions either.

## The full loop

## Creating a session

```ts
const session = await ctx.sessions.create({
  containerId: 'inbox',            // Placement into a container
  title: 'Chat with Alice',
  activity: 'interactive',         // or 'background'
  permissionMode: 'ask',           // interactive defaults to ask; background defaults to acceptCalls
  initialMessage: {
    text: 'Hello from the bot.',
    idempotencyKey: 'welcome-alice-2026-01-01',
  },
});
```

**Choose exactly one placement**, never both:

-   `containerId` — placed into your own container (the mini tool's default choice).
-   `space: { spaceId }` — placed into a regular Space's session list, appearing as if the user created it, while ownership still belongs to the mini tool.

**What `activity` actually changes**: a `background` session doesn't trigger a **system notification** when it completes or is waiting — only a red dot appears at the container entry — and defaults to `acceptCalls` permissions, suited for unattended tasks. `interactive` is a normal chat session.

**`context: 'caller'`** can only be used inside an agent tool's `execute()`. It makes the new session inherit the caller's cwd, model, policy, and Space context — useful for forking a side thread off the current conversation. Using it outside a tool call throws.

A `create()` call with `initialMessage` that fails does not leave a ghost session behind.

## Sending messages

`send()` is strictly FIFO within a single session:

```ts
const receipt = await ctx.sessions.send(session.sessionId, {
  text: 'What is the weather?',
  idempotencyKey: 'msg-123',       // required
});

if (receipt.state === 'rejected') {
  // Queue full — retry after receipt.retryAfterMs, don't hammer it immediately
}
```

`idempotencyKey` is required, up to 512 characters. **Use a stable message ID from the external source, not a random UUID** — resending it returns the original receipt instead of starting a new turn.

Limits: 100,000 characters of text; 10 attachments per message, 20MB each, 20MB total.

## Getting results: three APIs, three jobs

| API | Purpose |
| --- | --- |
| `waitForTurn(sessionId, turnId)` | You need the **exact final result** of this turn right now — request/response-style orchestration |
| `onDidReceiveEvent(cb)` | **Long-term observation** across multiple sessions and turns — bot scenarios |
| `listEvents({ sessionId, after })` | For **historical replay and reconnect recovery only** |

```ts
const result = await ctx.sessions.waitForTurn(
  sessionId, receipt.turnId, { timeoutMs: 60_000 },
);
if (result.state === 'completed') console.log(result.outputText);
if (result.state === 'failed')    console.error(result.code);
if (result.state === 'timeout')   console.log('Still running');
```

Timeout defaults to 60 seconds, clamped between 1–600 seconds. **A timeout only ends the wait — it doesn't cancel the turn itself.**

Among event types, `assistant.delta` is a **real-time streaming fragment, never persisted**; reconnect recovery must rely on `assistant.message` or `turn.completed`. Events are retained for 7 days, up to 10,000 per mini tool.

**Never `sleep` and poll `listEvents()`** — use `waitForTurn()` instead.

## Orchestration pattern: Planner → Worker → Writer

The standard shape for multi-agent orchestration: the main tool breaks down a task in a single call, spawns parallel child sessions, waits for all results, and aggregates the output.

```ts
const results = await Promise.all(tasks.map(async (task, i) => {
  const s = await ctx.sessions.create({
    containerId: 'workers',
    activity: 'background',
    context: 'caller',
    initialMessage: { text: task, idempotencyKey: `job-${jobId}-${i}` },
  });
  const r = await ctx.sessions.waitForTurn(s.sessionId, s.turnId, { timeoutMs: 300_000 });
  return r.state === 'completed' ? r.outputText : `Task ${i} failed`;
}));
```

Use `background` so child sessions don't interrupt the user, while the user can still click into the container to watch each child session's full progress.

## Quotas

| Limit | Value |
| --- | --- |
| Outstanding turns per session | 20 |
| Outstanding turns per mini tool | 200 |
| Retry interval when queue is full | 1000 ms |
| Event retention count / duration | 10,000 events / 7 days |

## Common mistakes

-   Forgetting to declare `permissions.sessions` or `contributes.sessionContainers`.
-   Passing a `containerId` not declared in the manifest.
-   Omitting `idempotencyKey`, so every external webhook retry starts a new turn.
-   Using `context: 'caller'` outside a tool call.
-   Assuming `assistant.delta` is persisted.
-   Not handling `send()`'s `rejected` queue-full state.
-   Creating a new session for every external message instead of reusing one session per contact.
