Context and shared context
contextOne request context every interface reads, and consent-gated, first-party shared context (settings and progress) between peer apps of one owner on one machine.
contextFree in your app, Apache-2.0Status Status is where the work is: Planned (in a milestone, not started), Building (tickets in progress), Preview (usable, may change), then the stability it ships at.
Stability The promise an interface ships with.
- Stable Follows semantic versioning; breaking changes need a new major, 12 months of overlap and a codemod.
- Beta Usable in production; may change before it becomes stable.
- Experimental May change or go away; needs an explicit opt-in in main.yaml.
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.
At a glance
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: []import { context } from "mainsdk";
// sent only to peers named under the purpose, and only with the person's consent on both sides
await context.shared.put({ purpose: "personalise", attributes: { locale: "hi-IN", theme: "dark" } });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 })
Drivers
1 drivers. Select one to see its line in main.yaml.
context:
driver: builtinChange this one line to swap providers; your calls stay the same.Built-in and local drivers integration
Codes
| Code | Where it appears | Meaning |
|---|---|---|
MAIN-CONTEXT-0001 | Checkmainsdk check, before deploy | 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 | Checkmainsdk check, before deploy | 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 | RuntimeError responses and logs | See the spec |
MAIN-CONTEXT-0003 | RuntimeError responses and logs | See the spec |
MAIN-CONTEXT-0004 | RuntimeError responses and logs | See the spec |
MAIN-CONTEXT-0005 | RuntimeError responses and logs | See the spec |
MAIN-CONTEXT-0007 | RuntimeError responses and logs | See the spec |
Features
Nothing here has shipped yet. Each feature lands in its phase; the spec ticket builds it.
| Feature | Phase | Milestone | Spec ticket |
|---|---|---|---|
| The mainsdk-context@1 schema and purposes | Phase 1 | 0.4 | context#schema |
| Consent gating | Phase 1 | 0.4 | context#consent |
| Shared identity from auth or a first-party id | Phase 1 | 0.4 | context#identity |
| Context over the app link | Phase 1 | 0.4 | context#bus |
| Minimisation, retention and tenant isolation | Phase 1 | 0.4 | context#minimise |
| Export and erasure with the person | Phase 1 | 0.4 | context#lifecycle |
Quick start
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]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
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:
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.
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,childorphi, 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-0005for 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(dborevents); anything else is refused at check. - A children's app (
users.audience: children) must mark each purposechildren: 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.
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
Was this page helpful?