---
title: "Context and shared context"
description: "One request context every interface reads, and consent-gated, first-party shared context (settings and progress) between peer apps of one owner on one machine."
section: "interfaces"
status: "Building for 0.4 · ships as a beta API"
spec: "context"
canonical: "https://mainsdk.dev/docs/interfaces/context"
updated: "2026-10-28"
---

# Context and shared context

> One request context every interface reads, and consent-gated, first-party shared context (settings and progress) between peer apps of one owner on one machine.

Status: Building for 0.4 · ships as a beta API · Canonical: https://mainsdk.dev/docs/interfaces/context · Updated: 2026-10-28

`context` is the one request context every interface reads, plus consent-gated first-party context shared between peer apps of one owner on one machine.

> **Note:** Shared context is partly built (spec `context`, milestone 0.4). Built: the typed schema and its check, consent gating on both sides, the context id, minimisation, retention, tenant isolation, export and erasure with the person. Not built yet because they wait on other specs: the app link that carries messages between apps (`app-registry`, `net-sockets`) and the `privacy.consent`, `privacy.export` and `privacy.erase` calls (`privacy-lifecycle`). Until then the module reaches peers through a small `ContextLink` port and keeps consent in a local record, and the calls are reached as `app().sharedContext`. The module-level `context` import arrives with `cohesion`.

## At a glance

- Status: Building for 0.4 · ships as a beta API
- Phase: 1 (Core, public beta)
- Milestone: 0.4 Hardening
- Plan: free in your app (Apache-2.0)
- Spec: https://github.com/hubxero/mainsdk/blob/develop/docs/specs/context.md
- Category: Identity and access

Calls:

- `context.current()`
- `context.run(patch, fn)`
- `context.capture()`
- `context.restore(snap, fn)`
- `context.shared.put({ purpose, attributes?, events? })`
- `context.shared.get(purpose)`
- `context.shared.list({ purpose?, cursor?, limit? })`
- `context.shared.erase({ user?, purpose? })`
- `context.identity.reset()`
- `parseContext(raw, opts?)`
- `contextJsonSchema(extras?)`
- `deriveContextId(groupKey, { issuer, tenant, subject, epoch })`

In main.yaml:

```yaml
context:
  group: acme-learning
  identity: auth
  purposes:
    personalise:
      description: "Use the same language and theme in every Acme app"
      attributes: [locale, theme]
      retention: 30d
      share-with: [prj_01JD8X]
  fields:
    attributes:
      favourite-subject: { type: string, enum: [maths, science] }
    events: [lesson.completed]
  sinks: []
```

Guarantees, whatever the driver:

- Nothing flows without a recorded purpose and consent on both sides
- Never an advertising or device id; the person can reset the id
- Stays on the machine unless a sink inside the app is declared


## Drivers

Pick one in main.yaml with `context.driver`.

| Driver | Id in main.yaml | Default | Note |
| --- | --- | --- | --- |
| Built in (the app link) | `builtin` | yes |  |

## Codes

| Code | Where | Meaning |
| --- | --- | --- |
| `MAIN-CONTEXT-0001` | check | `context` is invalid: no `group`, a purpose without a description, a field outside the built-in schema or the app's declared extras, `retention` over 90d, a `share-with` app that isn't a mutual peer, or a sink other than `db` or `events` |
| `MAIN-CONTEXT-0006` | check | Shared context in an app with `users.audience: children`, or a purpose touching a children's audience, without the purpose marked `children: guardian-consent` and spec privacy-lifecycle's age gate in place |
| `MAIN-CONTEXT-0002` | runtime |  |
| `MAIN-CONTEXT-0003` | runtime |  |
| `MAIN-CONTEXT-0004` | runtime |  |
| `MAIN-CONTEXT-0005` | runtime |  |
| `MAIN-CONTEXT-0007` | runtime |  |

## Features

| Feature | Phase | Milestone | Spec ticket |
| --- | --- | --- | --- |
| The mainsdk-context@1 schema and purposes | 1 | 0.4 | `context#schema` |
| Consent gating | 1 | 0.4 | `context#consent` |
| Shared identity from auth or a first-party id | 1 | 0.4 | `context#identity` |
| Context over the app link | 1 | 0.4 | `context#bus` |
| Minimisation, retention and tenant isolation | 1 | 0.4 | `context#minimise` |
| Export and erasure with the person | 1 | 0.4 | `context#lifecycle` |

## MainSDK Cloud

Everything on this page is free in your app.


## Quick start

```yaml title="main.yaml"
context:
  group: acme-learning
  identity: auth
  purposes:
    personalise:
      description: "Use the same language and theme in every Acme app"
      attributes: [locale, theme]
      retention: 30d
      share-with: [prj_01JD8X]
```

```ts title="app/personalise.ts"
import { app } from "mainsdk";

const ctx = app().sharedContext;
const user = { id: "u_123" };

// 1. Ask: show the description and the apps that receive it, then record the person's answer.
const ask = ctx.consent.ask("personalise");
await ctx.consent.set(user, "personalise", true);

// 2. Share: sent only to peers named under the purpose, and only when both apps hold a yes.
const { sent, held, refused } = await ctx.shared.put(
  { purpose: "personalise", attributes: { locale: "hi-IN", theme: "dark" } },
  { user },
);

// 3. Read what peers shared with this app.
const { attributes } = await ctx.shared.get("personalise", { user });
```

## Configuration

```yaml title="main.yaml"
context:
  group: acme-learning          # required: lower-case letters, digits and dashes
  identity: auth                # auth | first-party | both
  purposes: {}                  # one entry per declared purpose, see below
  fields: {}                    # up to 16 extra attributes and event types
  sinks: [db]                   # optional onward copies: db or events
```

| Key | Type | What it does |
| --- | --- | --- |
| `group` | string | The apps that share context. Required; peers must declare the same group |
| `identity` | string | `auth` derives the id from the signed-in subject, `first-party` makes a random one, `both` allows either |
| `purposes.<name>` | map | `description`, `attributes`, `events`, `retention` (default 30d, at most 90d), `share-with` and, for a children's app, `children: guardian-consent` |
| `fields` | map | Extra `attributes` and `events` beyond the seven built-in keys |
| `sinks` | list | Where received context may be copied on this host: `db` or `events`; anything else fails `MAIN-CONTEXT-0001` |

### Purposes

Every flow is for a declared purpose. A purpose has a `description` shown to the person, the `attributes` and `events` it may carry, a `retention` (default 30d, at most 90d) and `share-with`, the peer apps that receive it. Consent is per purpose; there is no "all purposes" switch. Built-in attribute keys are `locale`, `timezone`, `theme`, `reduced-motion`, `text-size`, `high-contrast` and `units`. Declare up to 16 extra attributes and event types under `context.fields`:

```yaml title="main.yaml"
context:
  fields:
    attributes:
      favourite-subject: { type: string, enum: [maths, science] }
      streak: { type: number }
    events: [lesson.completed]
```

A string field needs an `enum` or a `max-length` of at most 64.

### Identity

`identity: auth` derives the id from the signed-in subject, so peers that sign people in through one issuer get the same `cx_` id without exchanging the subject. `identity: first-party` makes a random id when the person first grants a purpose, stored in the group's store. The id differs per tenant and per group, and is never derived from a device, hardware, advertising id, IP address, fingerprint or email. The group key is 32 random bytes in `<run>/context/<group>.key` (mode 0600) and never leaves the machine.

## What you get

`context` has two halves. The request context (`context.current()`, `context.run()`) is one place every interface reads who is calling and on which trace; it belongs to spec `cohesion` and is built: one `AsyncLocalStorage` in `core/context.ts` holds tenant, user, agent, region, trace, deadline and signal; adapters set it once per request; jobs, workflow runs and outbox rows save `context.capture()` and run inside `context.restore()`, so a log record carries the request's `trace_id` and agent in the job and every workflow step. **Shared context** (`context.shared.*`, `context.identity.reset()`) lets several MainSDK apps from one publisher on one machine share a small typed picture of a person (language, theme, accessibility settings, a finished lesson) so each app serves them better, and only because that person said yes to a stated purpose. It is not an ad-tracking SDK: no advertising or device id is ever involved, nothing leaves the machine unless you declare where, and the person can see it, export it, reset the id that links it and erase it everywhere.

Single apps and single serverless apps get nothing from shared context. For shared sign-in across separate deployments, use [`auth`](https://mainsdk.dev/docs/interfaces/auth).

### Calls

| Call | What it does |
| --- | --- |
| `context.shared.put({ purpose, attributes?, events? })` | Share fields with the peers the purpose names. Returns `sent`, `held` (peers without consent) and `refused` (fields or peers refused, with the code). |
| `context.shared.get(purpose)` | Read the current person's received attributes and events for a purpose, never past its retention. |
| `context.shared.list({ purpose?, cursor?, limit? })` | The current person's flows by purpose and peer, for their own data page. It never lists other people. |
| `context.shared.erase({ user?, purpose? })` | Erase the person's context here and in every peer that received it. Returns a receipt, the peers reached and those still pending. |
| `context.identity.reset()` | The person's own action: erase their context in every peer, then change the id, so old data no longer links to them. |
| `parseContext(raw, opts?)` | Read the `context` section of `main.yaml` into settings and a list of issues, without throwing. |
| `contextJsonSchema(extras?)` | The JSON Schema of a shared context (`mainsdk-context@1`), with any extra fields the app declares. |
| `deriveContextId(groupKey, { issuer, tenant, subject, epoch })` | The same pseudonymous id for the same person in every app of a group, derived with an HMAC of the group key. |
| `context.current()`, `context.run(patch, fn)`, `context.capture()`, `context.restore(snap, fn)` | The request context of spec `cohesion`. `current()` reads it, `run` changes user, agent, region, env, trace, deadline, signal or debug for one call (the tenant changes only through `tenant.run` and `tenant.system`), `capture()` returns it as JSON and `restore` runs a function inside a saved one with a new span in the same trace. |

### What it always does

- Nothing flows without a recorded purpose and consent in both apps. Without a yes here nothing is sent; a peer without its own yes drops the message and answers `held`.
- Only fields the purpose lists are sent or stored. Strings are at most 64 characters; fields labelled `personal`, `sensitive`, `child` or `phi`, and values that look like a secret, email, phone number or card, are refused (`MAIN-CONTEXT-0004`).
- Received context is stored per tenant, expires at the purpose's retention and is read only for the same tenant and person (`MAIN-CONTEXT-0005` for a cross-tenant message).
- Withdrawing consent stops the flow at once and sends an erase to every peer that received it. A peer that is down gets the erasure queued and is listed as pending (`MAIN-CONTEXT-0007`).
- Every flow is audited with the purpose, the peer, field names and counts, never a value and never a raw user id.
- It stays on the host. The only onward copies are the sinks you declare under `context.sinks` (`db` or `events`); anything else is refused at check.
- A children's app (`users.audience: children`) must mark each purpose `children: guardian-consent` (`MAIN-CONTEXT-0006`).

### See what is shared

`mainsdk context` (and `mainsdk info`) show the group, purposes, `share-with`, sinks and, per purpose, how many people consented and how many flows went out and came in. They hold no context id, user id or value.

## Errors and checks

| Code | When | What to do |
| --- | --- | --- |
| `MAIN-CONTEXT-0001` | `check`: `context` is invalid (no group, a purpose without a description, an unknown field, retention over 90d, a `share-with` app that is not a mutual peer, a sink other than `db` or `events`) | Fix the key the finding names |
| `MAIN-CONTEXT-0002` | A share or receive was held: no recorded consent on this side or the peer's | Record the person's yes for the purpose |
| `MAIN-CONTEXT-0003` | A peer is not first-party: another owner, or another group | Use peers of one owner and one group |
| `MAIN-CONTEXT-0004` | A field or value was refused: unlisted, over 64 characters, labelled, or looks like a secret, email, phone or card | Send only fields the purpose lists |
| `MAIN-CONTEXT-0005` | A cross-tenant flow was refused | Share only inside one tenant |
| `MAIN-CONTEXT-0006` | `check`: a children's app without `children: guardian-consent` on the purpose | Mark the purpose |
| `MAIN-CONTEXT-0007` | An erasure could not reach a peer; it is queued and listed as pending | Start the peer; it erases on its next start |

Each code's cause and fix is in [check codes](https://mainsdk.dev/docs/reference/check-codes).

## Swapping drivers

| Driver | Provider | |
| --- | --- | --- |
| `builtin` | Built in (the app link) | default |

`builtin` is the only driver, so there is nothing to swap yet. Your calls do not depend on how peers are reached.

## Testing

`createTestApp` builds the app from `main.yaml`, so `app.sharedContext` runs the same consent and minimisation rules in memory. The unit tests for the schema, consent, tenant isolation and erasure live in `packages/sdk/test/context-shared.test.ts` and `packages/testing/test/context-shared.test.ts`.

## Next

- [Share context between your apps](https://mainsdk.dev/docs/guides/share-context-between-your-apps)
- [Sign-in and identity](https://mainsdk.dev/docs/interfaces/auth)
- [Check codes](https://mainsdk.dev/docs/reference/check-codes)
