> ## Documentation Index
> Fetch the complete documentation index at: https://honcho.dev/docs/llms.txt
> Use this file to discover all available pages before exploring further.

# Scopes

> Named sets of sessions that act as visibility boundaries for recall

A **scope** is a named set of sessions that acts as a visibility boundary. Recall
performed through a scope sees only what happened in that scope's sessions,
while the peer keeps its single unified representation of everything it has ever
participated in.

Use scopes when one peer's history spans contexts that must not leak into each
other — a therapy app where the clinical sessions must not inform the billing
assistant, a support product where a reseller's agent may only answer from its
own tickets, a multi-tenant deployment where one human works across tenants.

## Projection, Not Partition

The peer keeps one representation. A scope is a **projection** of it: a view
built only from evidence in the member sessions.

```mermaid theme={null}
graph TB
      P[Peer: user-123<br/>one unified representation]

      P --> S1[session: therapy-1]
      P --> S2[session: therapy-2]
      P --> S3[session: billing-1]
      P --> S4[session: onboarding-1]

      SC1[scope: therapy] -.->|projects| S1
      SC1 -.->|projects| S2
      SC2[scope: billing] -.->|projects| S3

      style P fill:#B6DBFF,stroke:#333,color:#000
      style S1 fill:#B6DBFF,stroke:#333,color:#000
      style S2 fill:#B6DBFF,stroke:#333,color:#000
      style S3 fill:#B6DBFF,stroke:#333,color:#000
      style S4 fill:#B6DBFF,stroke:#333,color:#000
      style SC1 fill:#FFE0B2,stroke:#333,color:#000
      style SC2 fill:#FFE0B2,stroke:#333,color:#000
```

* **Sessions can belong to more than one scope.** Membership is many-to-many.
* **Sessions can belong to no scope.** `onboarding-1` above is reachable
  by an unscoped request and by nothing else.
* **An unscoped request still sees everything.** A scope constrains the requests
  that name it; it does not hide the sessions from requests that don't.

> [!WARNING]
>
> Passing a scope option with a workspace/admin key is cooperative filtering.
> A named-scope key enforces the boundary on the server, even when the caller
> omits the option. Existing admin, workspace, peer, and session keys keep their
> authorization behavior; they do not need to be replaced.

## Named-scope API keys

With authentication enabled, an admin can mint a key bound to one named scope:

```bash theme={null}
curl -X POST "$HONCHO_BASE_URL/v3/keys?workspace_id=company&scope_id=support" \
  -H "Authorization: Bearer $HONCHO_ADMIN_KEY"
```

Create the workspace and scope with a privileged key before using the restricted
key. The key can recall **and ingest**; there are no read-only/write-only roles.
It carries `w` (workspace), `sc` (unprefixed scope name), and the audience
`honcho:named-scope`, optionally `exp`.
It cannot also carry an admin grant, peer, or session restriction. Existing JWT
claims and minting parameters are unchanged.

For self-hosting, the signing script also supports this key type:

```bash theme={null}
uv run python scripts/generate_jwt.py --workspace company --scope support --expires 8h
```

These are core Honcho JWTs. Honcho Cloud's managed `hch-` keys require separate
gateway support for named-scope grants; do not assume a core JWT can be
used at `api.honcho.dev` or that the managed key endpoint preserves `scope_id`.
Use an instance running this core implementation directly until managed support
is available. Upgrade **every** API replica before issuing scope keys. Older
servers reject the dedicated audience, so a mixed-version fleet or rollback
rejects new scope keys rather than widening their access. Existing keys continue
working on both versions without rotation. Tokens with `sc` but without the
dedicated audience are rejected by the new server as well.

| Operation | With a named-scope key |
| - | - |
| Peer chat, streaming chat, representation | Automatically use the key's named projection; another scope or a list is rejected |
| Session context | Requires active membership; target representation and card use the key's projection |
| Messages: create, upload, list, get, update metadata | Allowed in active member sessions |
| Session summaries and message search | Allowed in active member sessions |
| Create/get session | A fresh session joins the key's scope; an existing session must already be an active member, and the call cannot change its metadata, configuration, or peers |
| Get scope, list its sessions, backfill status | Only the key's scope |
| Add/remove scope membership, create scopes, mint keys | Requires a privileged key |
| Workspace-wide recall/listing, global peer context/cards, generic conclusions, other resource management | Rejected |

For recall, omit `scope` or pass exactly the key's scope name as a string.
`filters`/`session_id` cannot replace that projection, and session context rejects
`sessions`, `limit_to_session`, or `peer_perspective` overrides. A scope key may
query any target peer, but only through what its scope knows about that target.
An empty scope has no evidence; it never falls back to a global view.

On session creation, omit `scopes` or pass exactly `["support"]`. Naming an
existing unrelated session cannot enroll it. Message authors are ordinary peers,
created as needed through ingestion; scope peers cannot author messages. A
session shared by multiple scopes is one underlying conversation: writes feed
all its observers. Scope keys are trusted writers for their member sessions.

The current high-level SDK/CLI helpers attempt workspace provisioning, including
before many reads. A named-scope key correctly rejects that global operation.
Use the SDK's HTTP transport or REST for scope-key operations until those helpers
support restricted credentials. Existing high-level clients with existing key
types are unchanged. For Python:

```python theme={null}
import os
from honcho.http import HonchoHTTPClient

client = HonchoHTTPClient(
    api_key=os.environ["HONCHO_SCOPE_KEY"],
    base_url=os.environ["HONCHO_BASE_URL"],
)
root = "/v3/workspaces/company"
client.post(f"{root}/sessions", body={"id": "support-thread"})
client.post(f"{root}/sessions/support-thread/messages", body={"messages": [
    {"peer_id": "alice", "content": "I prefer concise technical answers."}
]})
answer = client.post(f"{root}/peers/alice/chat", body={
    "query": "How should I explain this to Alice?"
})
context = client.get(f"{root}/sessions/support-thread/context", query={
    "peer_target": "alice"
})
client.close()
```

In TypeScript, use the public `client.http` transport for these endpoints.
Direct `new Peer(id, client.workspaceId, client.http)` and
`new Session(id, client.workspaceId, client.http)` handles also avoid the factory's
workspace provisioning callback. Do not catch a scope-key rejection and retry
with a broader credential.

Membership is checked on each session request. Removing a session immediately
denies subsequent direct session reads and writes and excludes it from raw
message recall. **Learned memory cleanup remains asynchronous**: existing
conclusions and cards may contain removed evidence until reconciliation finishes.
Requests already in flight and application caches are not retroactively erased.
Applications needing immediate revocation must suspend affected scope recall,
invalidate cached context, and wait for verified cleanup or rebuild a fresh scope.

JWTs retain existing expiration and signing-secret semantics; this change does
not add a token revocation registry. Prefer expiring grants. Scope IDs should
not be reused to repurpose an outstanding grant.

## The Two Arms

There are two ways to confine recall, and they behave differently. Picking the
wrong one is the most common mistake with this feature.

| | `scope="therapy"` (named scope) | `sessions=[...]` / `scope=["a","b"]` (allowlist) |
| - | - | - |
| **Mechanism** | Reads the scope's own representation of the peer | Restricts the peer's own representation to a set of sessions |
| **Conclusions** | All levels — `explicit`, plus `deductive` / `inductive` reasoned **within** the scope | `explicit` only |
| **Reasoning chains** | Available | Unavailable |
| **Setup required** | Yes — create the scope, add sessions, wait for backfill | None — pass session IDs ad hoc |
| **Accepts** | One scope name | A list of up to 100 scope names, or up to 1,000 session IDs |

### Named scope: depth

Passing a **single** scope name swaps the observer. Recall runs against the
scope's own view of the target peer, which the deriver and dreamer have been
building from the scope's member sessions all along. That view contains
higher-order inferences — but only ones reasoned from evidence inside the scope.

```python theme={null}
answer = user.chat("What is stressing them out?", scope="therapy")
```

This is the arm you want for a durable, meaningful boundary.

### Allowlist: breadth

Passing a **list** of scopes, or a bare list of session IDs, keeps the peer as
the observer and restricts recall to the union of those sessions. Because a
dream-derived conclusion is synthesized across sessions, it cannot be attributed
to any one of them — so this arm recalls `explicit` conclusions only, and answers
from directly-stated facts rather than inference.

```python theme={null}
answer = user.chat("What did they say about billing?", sessions=[s1, s2])
answer = user.chat("What did they say?", scope=["therapy", "intake"])
```

Reach for this when the set of sessions is decided per-request, or when you want
a quick boundary without provisioning a scope. See
[Scoping Recall to Sessions](https://honcho.dev/docs/v3/documentation/features/advanced/using-filters.md#scoping-recall-to-sessions)
for the full allowlist rules.

> [!NOTE]
>
> A list of scopes is the allowlist arm, not "several named scopes at once". It
> gives you the union of their *sessions*, at explicit-only depth — it does not
> give you the union of their reasoned views. If you need depth, query one scope.

## Creating a Scope and Managing Membership

```python Python theme={null}
from honcho import Honcho

honcho = Honcho(workspace_id="my-app")

# Get or create — idempotent; passing metadata updates the existing scope
therapy = honcho.scope("therapy")

# Look up without creating — raises NotFoundError if the scope doesn't exist
existing = honcho.get_scope("therapy")

# Add existing sessions (max 100 per call)
therapy.add_sessions(["therapy-session-1", "therapy-session-2"])

# Or attach at session creation — the scope is created if it doesn't exist
session = honcho.session("therapy-session-3", scopes=["therapy"])

# Inspect
for s in therapy.sessions():
    print(s.id)

therapy.remove_session("therapy-session-1")

# Or remove up to 100 at once — all-or-nothing if any session doesn't exist;
# sessions that aren't members are ignored
therapy.remove_sessions(["therapy-session-2", "therapy-session-3"])

for scope in honcho.scopes():
    print(scope.id, scope.metadata)
```

```typescript TypeScript theme={null}
import { Honcho } from "@honcho-ai/sdk";

const honcho = new Honcho({ workspaceId: "my-app" });

// Get or create — idempotent; passing metadata updates the existing scope
const therapy = await honcho.scope("therapy");

// Look up without creating — rejects with NotFoundError if the scope doesn't exist
const existing = await honcho.getScope("therapy");

// Add existing sessions (max 100 per call)
await therapy.addSessions(["therapy-session-1", "therapy-session-2"]);

// Or attach at session creation — the scope is created if it doesn't exist
const session = await honcho.session("therapy-session-3", {
  scopes: ["therapy"],
});

// Inspect
for await (const s of await therapy.sessions()) {
  console.log(s.id);
}

await therapy.removeSession("therapy-session-1");

// Or remove up to 100 at once — all-or-nothing if any session doesn't exist;
// sessions that aren't members are ignored
await therapy.removeSessions(["therapy-session-2", "therapy-session-3"]);

for await (const scope of await honcho.scopes()) {
  console.log(scope.id, scope.metadata);
}
```

```bash REST theme={null}
# Get or create (201 created / 200 existing)
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes" \
  -H "Authorization: Bearer $HONCHO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"id": "therapy"}'

# Look up without creating (404 if missing)
curl "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy" \
  -H "Authorization: Bearer $HONCHO_API_KEY"

# Add sessions
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions" \
  -H "Authorization: Bearer $HONCHO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"session_ids": ["therapy-session-1", "therapy-session-2"]}'

# List membership
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions/list" \
  -H "Authorization: Bearer $HONCHO_API_KEY"

# Remove one session
curl -X DELETE "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions/therapy-session-1" \
  -H "Authorization: Bearer $HONCHO_API_KEY"

# Remove up to 100 sessions (404 and no change if any doesn't exist;
# non-members are ignored)
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/sessions/remove" \
  -H "Authorization: Bearer $HONCHO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"session_ids": ["therapy-session-2", "therapy-session-3"]}'
```

Scope IDs are unprefixed, must match `^[a-zA-Z0-9_-]+$`, and are at most 506
characters. Get-or-create is idempotent: if the scope already exists, the same
call returns it, and any `metadata` you pass is written onto it. When a lookup
must not create a scope — resolving a user-supplied name, for example — use
`get_scope` / `getScope` instead, which fails with a not-found error.

### Searching and Counting Scopes

The scope list can filter by name and report how many sessions each scope
holds, so a client never has to load every scope or list each one's members:

```python Python theme={null}
# `id` matches the scope name (never the internal prefix)
for scope in honcho.scopes({"id": {"icontains": "sup"}}, include_session_count=True):
    print(scope.id, scope.session_count)
```

```typescript TypeScript theme={null}
const scopes = await honcho.scopes({
  filters: { id: { icontains: "sup" } },
  includeSessionCount: true,
});
for await (const scope of scopes) {
  console.log(scope.id, scope.sessionCount);
}
```

```bash REST theme={null}
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/scopes/list?include_session_count=true" \
  -H "Authorization: Bearer $HONCHO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"filters": {"id": {"icontains": "sup"}}}'
```

`session_count` counts the sessions `scopes/{id}/sessions/list` would return,
and is omitted unless requested. The name filter takes the same operators as
other list filters (`icontains`, `contains`, `in`, AND/OR/NOT), plus
`created_at` and `metadata`; `total` counts the filtered scopes.

> [!NOTE]
>
> Every scopes route — and every read that passes `scope` — requires a
> **workspace-level or admin key**. A scope's membership can exceed any single
> peer's own session membership, so peer- and session-scoped keys are rejected
> with `401`.

### Listing Membership From the Session Side

The workspace session list can answer the reverse question — which scopes is
this session in? — without listing every scope's members:

```python Python theme={null}
# Each listed session's `scopes` holds its scope names, sorted ([] for none)
for s in honcho.sessions(include_scopes=True):
    print(s.id, s.scopes)

# Only the sessions in one scope, or only the sessions in no scope
therapy_sessions = honcho.sessions(scope="therapy")
unscoped = honcho.sessions(in_no_scope=True)
```

```typescript TypeScript theme={null}
// Each listed session's `scopes` holds its scope names, sorted ([] for none)
for await (const s of await honcho.sessions({ includeScopes: true })) {
  console.log(s.id, s.scopes);
}

// Only the sessions in one scope, or only the sessions in no scope
const therapySessions = await honcho.sessions({ scope: "therapy" });
const unscoped = await honcho.sessions({ inNoScope: true });
```

```bash REST theme={null}
# Each session in the page carries `scopes`: its active scope names, sorted
# ([] for a session in no scope). Without the flag, sessions have no `scopes` key.
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/sessions/list?include_scopes=true" \
  -H "Authorization: Bearer $HONCHO_API_KEY"

# Only the sessions in one scope (404 if the scope doesn't exist)
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/sessions/list" \
  -H "Authorization: Bearer $HONCHO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"scope": "therapy"}'

# Only the sessions in no scope
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/sessions/list" \
  -H "Authorization: Bearer $HONCHO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"in_no_scope": true}'
```

`scope` and `in_no_scope` are mutually exclusive, and both combine with
`filters`, pagination and `include_scopes`. The page's `total` counts the
filtered sessions, so `{"in_no_scope": true}` with `size=1` tells you how many
sessions are in no scope.

## Membership Changes Copy, They Don't Re-Derive

A session added to a scope while empty needs nothing special: messages sent
after the change flow into the scope through the normal deriver fan-out.

A session that **already has messages** is handled retroactively by a background
job rather than by re-running the LLM over its history: adding it copies the
session's existing `explicit` conclusions into the scope, and removing it
retracts that session's contributions — including conclusions derived from them.
Copying rather than re-deriving is why membership changes are cheap and
deterministic — and why they are also **asynchronous**. It also means a freshly
backfilled scope starts at explicit depth and accrues deeper reasoning through
subsequent dreams.

Poll `status()` to tell "the scope hasn't caught up yet" apart from "the scope
has caught up and there is genuinely nothing to recall":

```python Python theme={null}
therapy.add_sessions(["old-session-with-history"])

status = therapy.status()
# {"old-session-with-history": {"state": "pending", "updated_at": "..."}}
# → later: {"state": "completed", "docs_copied": 42, "updated_at": "..."}
```

```typescript TypeScript theme={null}
await therapy.addSessions(["old-session-with-history"]);

const status = await therapy.status();
// { "old-session-with-history": { state: "pending", updatedAt: "..." } }
```

```bash REST theme={null}
curl "$HONCHO_URL/v3/workspaces/my-app/scopes/therapy/status" \
  -H "Authorization: Bearer $HONCHO_API_KEY"
```

`state` is `pending`, `completed`, or `failed`; `docs_copied` appears once a
backfill completes. Only sessions that have had a backfill enqueued appear, so an
empty result means none have — not that the scope is empty.

## Reading Through a Scope

`scope` is accepted on these surfaces:

| Surface | Accepts | Notes |
| - | - | - |
| [`peer.chat()`](https://honcho.dev/docs/v3/documentation/features/chat) | one scope or a list | Confines both conclusion recall and the messages the agent reads |
| `peer.representation()` | one scope or a list | Confines conclusion recall |
| [`session.context()`](https://honcho.dev/docs/v3/documentation/features/get-context) | one scope only | Perspective source for `peer_target`'s representation and card. Requires `peer_target`; mutually exclusive with `peer_perspective` |
| `honcho.search()` | one scope only | Restricts message search to the scope's member sessions |
| [`honcho.chat()`](https://honcho.dev/docs/v3/documentation/features/chat#workspace-chat) | one scope or a list | Always the allowlist arm — even a single name. There is no observer to swap, and no `target` |

```python Python theme={null}
# Chat — answered only from the therapy sessions
answer = user.chat("What is stressing them out?", scope="therapy")

# Representation
rep = user.representation(scope="therapy")

# Session context, using the scope as the perspective source
ctx = session.context(peer_target="user-123", scope="therapy")

# Message search, restricted to the scope's sessions
messages = honcho.search("insomnia", scope="therapy")
```

```typescript TypeScript theme={null}
// Chat — answered only from the therapy sessions
const answer = await user.chat("What is stressing them out?", {
  scope: "therapy",
});

// Representation
const rep = await user.representation({ scope: "therapy" });

// Session context, using the scope as the perspective source
const ctx = await session.context({
  peerTarget: "user-123",
  scope: "therapy",
});

// Message search, restricted to the scope's sessions
const messages = await honcho.search("insomnia", { scope: "therapy" });
```

```bash REST theme={null}
curl -X POST "$HONCHO_URL/v3/workspaces/my-app/peers/user-123/chat" \
  -H "Authorization: Bearer $HONCHO_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{"query": "What is stressing them out?", "scope": "therapy"}'
```

### Rules

`scope` is mutually exclusive with `filters`, `sessions`, and `session` /
`session_id` — and on session context, with `peer_perspective` (where it also
requires `peer_target`). Like the session allowlist, it **fails closed**: a
contradiction is rejected with a `422` rather than silently widened, a scope
with no member sessions recalls nothing, and an empty list (`scope=[]`) is
rejected rather than treated as "no boundary". On workspace chat the same
exclusion holds: pass `scope` or `session_id`, never both. Per-surface caps and error
shapes are in the [API reference](https://honcho.dev/docs/v3/api-reference/endpoint/scopes/get-or-create-scope.md).

## Provenance, Not Topic

A scope is defined by **where a fact was said**, not what it is about.

If a user mentions a therapy detail in a billing session, that conclusion is
formed from the billing session and lands in the `billing` scope. Querying
`scope="therapy"` will not find it, and querying `scope="billing"` will.

> [!WARNING]
>
> Scopes give you provenance-based privacy, not topic-based privacy. If you need
> "no clinical content in the billing assistant's answers" regardless of where it
> was said, that is content classification and has to be enforced above Honcho —
> by controlling what reaches which session in the first place, or by filtering
> the answer.

Design accordingly: keep the session boundary aligned with the confidentiality
boundary you actually care about, since that session boundary is the one scopes
can enforce.

## Guardrails

A few behaviors follow from how scopes are built:

* **The `scope.` prefix is reserved.** Creating a peer, or adding a peer to a
  session, with a `scope.`-prefixed name is rejected.
* **List scopes through the scopes surface.** `honcho.scopes()` /
  `POST /scopes/list` returns unprefixed ids. Peer listings hide scopes by
  default; `kind="scope"` on `POST /peers/list` returns the backing peers named
  `scope.<id>`, and `kind="all"` includes both regular peers and those backing
  peers.
* **A scope can't be observed.** No representation is formed *of* a scope, so a
  scope is rejected in any `target` / observed position, including as a dream
  target.
* **Membership is managed only through the scopes surface.** The session
  add-peers, set-peers, and remove-peers routes reject scope names and point you
  at `/scopes/{scope_id}/sessions` or the `scopes` field on session create.

If you want the exact mechanics for scopes, read: [`src/routers/scopes.py`](https://github.com/plastic-labs/honcho/blob/main/src/routers/scopes.py),
[`src/crud/scope.py`](https://github.com/plastic-labs/honcho/blob/main/src/crud/scope.py),
and [`src/deriver/scope_backfill.py`](https://github.com/plastic-labs/honcho/blob/main/src/deriver/scope_backfill.py).

## Limits

| Limit | Value |
| - | - |
| Scope ID length | 506 characters |
| Scope ID charset | `^[a-zA-Z0-9_-]+$` |
| Sessions per membership call | 100 |
| Scopes in one `scope` read option | 100 |
| Scopes on session create | 100 |
| Sessions in a resolved allowlist | 1,000 |

Full request and response shapes are in the
[API reference](https://honcho.dev/docs/v3/api-reference/endpoint/scopes/get-or-create-scope.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.