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

# Subjects

> An opaque identifier you supply. It is the isolation boundary for threads, runs, and memory — and Patchwork never resolves it.

A subject is a string you choose. Patchwork stores it, partitions on it, and returns it. We never parse it, validate its shape, look it up, or resolve it to a person.

That is the whole contract, and everything below follows from it. A subject can name a user, a team, a device, a tenant, a support ticket, or a nightly job. We have no opinion, because we have no way to form one.

```
"user_9f2"                  a person
"user_9f2:ws_acme"          a person, inside one of their workspaces
"slack:T04AB:U0APP"         a Slack user, namespaced by team
"ticket:8821"               a conversation, not a human at all
"job:nightly-recon"         a batch process
```

All five are equally valid. The only string we reject is an empty one.

## What counts as one string

We compare subjects byte for byte. There is no normalisation anywhere in the pipeline — we do not trim, case-fold, or canonicalise what you send.

```
"user_9f2"      three different subjects,
"User_9f2"      three separate thread histories,
"user_9f2 "     three separate memory partitions
```

The third is the one that bites: a trailing space from a template or a CSV column is invisible in a log and produces a partition you will never look at again. Normalise on your side, because we will not do it for you.

There is no length limit, but in relay the value travels in an HTTP header — keep it short enough that proxies do not object.

You never create a subject. It exists the moment you first use it, and a request naming a subject with no history simply starts one.

## What we do with it

The isolation key is always the pair `(workspace, subject)` — never the subject alone. Within your workspace, the subject partitions:

| Surface | Behaviour |
| - | - |
| **Threads** | A thread belongs to one subject. Listing threads returns only that subject's. |
| **Messages, runs, outcomes, confirmations** | Reachable only through a thread the subject owns. |
| **Memory** | Subject-scoped memories key on `(workspace, subject)`. Recall only ever reads that partition. |
| **Realtime** | A subject can subscribe only to its own thread channels. |
| **Erasure** | Deletion is keyed on the exact string. |

Ask for a thread belonging to a different subject and you get `404`, not `403`. The lookup is scoped, so the thread does not exist as far as that subject is concerned.

## How it reaches us

The subject arrives one of two ways, and the difference is who vouches for it.

| | Direct | Relay |
| - | - | - |
| **Carried in** | The `sub` claim of a session token you signed | The `Patchwork-Subject` header |
| **We verify** | Yes — against your public key | No |
| **Trust** | Verified | Asserted by your backend |
| **Precedence** | `sub` wins; a body `subject_ref` is ignored | Header wins over a body `subject_ref` |

In relay, the workspace API key authenticates *you*, not the subject. Anyone holding that key can assert any subject in your workspace. That is by design — your backend is the authority on who its users are — but it means the key must never reach a browser or a mobile client.

<Warning>
  **A subject-scoped endpoint requires a subject.** Omitting `Patchwork-Subject` in relay is a `400 subject_required` — it is not a wider mode, and there is no way to ask for every subject at once.

  Earlier versions answered a subject-less relay read with the whole workspace. Forgetting one header was then enough to hand one of your customers another customer's conversations, so it is now refused.
</Warning>

## Choosing your subject

One question decides it: **what is the smallest unit that must never see another unit's data?** That unit is your subject.

| Your product | Subject | Why |
| - | - | - |
| B2C app, one account per person | `user_9f2` | The account is the boundary. |
| B2B app, users belong to many tenants | `user_9f2:ws_acme` | The tenant is the boundary. See below. |
| Slack or WhatsApp bot | `slack:T04AB:U0APP` | Prefix by platform and team so ids cannot collide. |
| Shared team assistant | `ws_acme` | Everyone on the team *should* share memory. |
| Support inbox | `ticket:8821` | The conversation is the boundary; no human is stable across it. |
| Backend batch job | `job:nightly-recon` | No human involved at all. |

Get this wrong in the permissive direction and two people share a memory partition. There is no error, no warning, and no way for us to detect it — the strings matched, so as far as Patchwork is concerned they are the same subject.

## Composite subjects

Most B2B products need more than a user id. Consider an app where Jane belongs to two workspaces, Acme and Globex.

If her subject is `user_9f2`, she has **one** thread history and **one** memory partition across both. An agent that learned "the Q3 budget is \$2.4M" while she was working in Acme will recall it while she is working in Globex. That is a tenant data leak, authored in the subject.

The fix is to compose the identifier from every dimension that must stay separate:

```
user_9f2:ws_acme        Jane, in Acme
user_9f2:ws_globex      Jane, in Globex
```

Same human. Two subjects. Two thread lists, two memory partitions, no crossover — because to Patchwork these are simply two different strings.

### Minting a composite subject

In **direct**, the composite goes in the `sub` claim. Build it from your own session state:

```js theme={null}
app.post("/patchwork/mint", requireUser, (req, res) => {
  // The active workspace comes from the server session — never from the client.
  const subject = `${req.user.id}:${req.session.workspaceId}`;

  const token = jwt.sign(
    {
      iss: PATCHWORK_API_KEY_ID,
      aud: ["patchwork", "your-api"],
      sub: subject,
      conn: CONNECTION_ID,
    },
    PRIVATE_KEY,
    { algorithm: "RS256", expiresIn: 120, keyid: "your-app-2026-01" },
  );

  res.json({ token, expires_in: 120 });
});
```

In **relay**, your backend composes the same string and sends it as a header:

```bash theme={null}
curl -X POST https://api.usepatchwork.co/v1/loom/threads \
  -H "Authorization: Bearer sk_live_..." \
  -H "Patchwork-Subject: user_9f2:ws_acme" \
  -H "Content-Type: application/json" \
  -d '{"agent": "agent_..."}'
```

<Warning>
  **Derive every component server-side.** If the workspace id comes from a request parameter, a user can mint a subject for a workspace they do not belong to, and read that partition's threads and memory.

  Patchwork cannot catch this. We do not know that your string has two parts, that the second part is a workspace, or that Jane is not a member of it. Authorising the pair is your job, and it must happen before you build the string.
</Warning>

### Rules for composing

<ParamField path="Use an immutable component" type="required">
  Both halves must be ids that never change. An email, a username, or a workspace slug can be edited — and an edited subject is a new subject with an empty history.
</ParamField>

<ParamField path="Pick a delimiter your ids exclude" type="required">
  `:` is conventional. If your ids can contain it, `user:1:ws:2` is ambiguous. Use a delimiter your id format forbids, or fixed-prefix ids like `usr_9f2` and `ws_acme`.
</ParamField>

<ParamField path="Keep the order fixed" type="recommended">
  `user:workspace` and `workspace:user` are different strings. Pick one and never vary it — a flipped order in one code path silently creates a second partition.
</ParamField>

<ParamField path="Add dimensions deliberately" type="recommended">
  Every component multiplies your partitions. `user:workspace:project` gives a user a separate agent memory per project, which is either exactly what you want or an agent that never remembers anything.
</ParamField>

### What composition costs you

Two consequences worth accepting on purpose:

**No continuity across the boundary.** Jane gets no shared memory between Acme and Globex. For most B2B products that is the point. If you want a genuinely cross-tenant assistant, the composite is the wrong shape.

**Erasure fans out.** Deleting Jane means erasing every subject that contains her user id — one call per workspace she belongs to. Since erasure matches the exact string, you must be able to enumerate her memberships at deletion time. Keep that mapping in your own database; we cannot reconstruct it from the strings.

## Beyond one subject per person

The subject is a partition key, not an identity. That makes a few patterns available that a user id would not.

**Share deliberately.** Give every member of a team the same subject — `ws_acme` — and they share one thread history and one memory. A shared inbox assistant, an ops copilot, a channel bot. Everyone sees what everyone else told it, which is either the feature or a leak, depending on whether you chose it.

**Subjects without people.** A nightly reconciliation job is a legitimate subject. So is a device, a kiosk, or a webhook processor. Anything that should accumulate its own memory and history is a subject.

**Isolate test traffic.** Prefix non-production subjects — `test:user_9f2` — and you can erase every test partition without touching real data, and keep evaluation traffic out of production recall.

**Segment by lifetime.** If a conversation should start clean each time, put something ephemeral in the subject: `ticket:8821` gives one memory partition per ticket, which ends when the ticket does.

## Rules that always apply

<ParamField path="Never put PII in a subject" type="required">
  The value is stored verbatim, returned in thread and run responses, included in every event payload, and written to request logs. An email address in a subject is an email address in all of those places, permanently. Use your internal id.
</ParamField>

<ParamField path="Namespace across sources" type="required">
  If subjects come from more than one system, prefix each source. `slack:U0APP` and `wa:15551234567` cannot collide; a bare `U0APP` and a bare `15551234567` are one namespace away from doing so.
</ParamField>

<ParamField path="Treat it as permanent" type="required">
  A subject is the address of a memory partition. Changing it does not migrate anything — it abandons it.

  There is no rename, no merge, and no migrate. If you pick the wrong grain and need to change it later, your options are to leave the old subject in place and start a new one, or to erase the old partition and let the agent relearn. Choose the grain as carefully as you would a primary key.
</ParamField>

## Erasing a subject

Erasure runs from the dashboard today — there is no `/v1` endpoint for it. Find the subject under **Memory**, erase it, and every subject-scoped memory keyed on that exact string is removed.

Which puts the work back on your mapping. Deletion matches the string byte for byte, so you must erase every subject you minted for that person, including each composite you built from their id. If you cannot reproduce the string, we cannot find the data.

That is the practical cost of an identifier we never resolve, and the reason to keep your subject mapping durable rather than derived. See [Memory](/guides/memory) for what is stored and how long it lives.


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