Context
Context is a small set of notes your app keeps about a visitor. Your event handlers receive the notes with each event and run no query, and other apps on the site can use them if you allow it. Context lives with each visitor, so it grows with your traffic and you have no database to size. For what context is and why it exists, read Context first.
The two levels
Section titled “The two levels”| Private | Shared | |
|---|---|---|
| Who can read it | Only the app that wrote it | Every app on the site with sharedContext |
| Who can write it | Each app, its own entry | Each app with sharedContext, its own entry |
| Capability | None | sharedContext |
| Reaches the browser | Never | Yes, and browser code can only read it |
| Size of one entry | 32 KiB | 4 KiB |
Your app picks the level each time it writes, and nothing moves from one level to the other on its own.
An entry belongs to the app, not to an install. If a site installs your app twice, both installs read and write the same entries for a visitor. Put the install id in your keys if the installs must keep separate values.
What an entry holds
Section titled “What an entry holds”An entry has two independent parts and a timestamp.
kvis a map of keys to values. A value is a string, a number or a boolean, and it comes back as the same type.textis one free-form string, ornull. Use it for something a person or a language model reads, such as a summary.updatedAtis when the entry last changed, in milliseconds since the epoch.
An entry keeps only its current value. It has no history, so read updatedAt if you need to know how fresh a value is.
Use context in your app
Section titled “Use context in your app”Private context needs no declaration. To use shared context, add the capability to your manifest.
{ "capabilities": { "data": ["sharedContext"] }}An event handler receives both levels as plain values and writes with set.
import { defineApp } from '@blotout/qinto-sdk-app/server'
export default defineApp({ onEvent: { track: async (action, ctx) => { ctx.context.private.set({ kv: { last_event: action.payload.event } })
if (action.payload.event === 'Purchase') { ctx.context.shared.set({ kv: { has_purchased: true } }) }
// Read what another app shared. The key is that app's slug. const tier = ctx.context.shared.byApp['loyalty']?.kv['tier'] if (tier !== undefined) { ctx.observability.log('visitor tier', { tier }) } } }, onDeleteRequest: async () => {}})set merges a patch into your entry. It never replaces the whole entry.
| In the patch | What happens |
|---|---|
| A key with a value | The key is written |
A key set to null |
The key is deleted |
| A key you leave out | The stored value stays |
text with a string |
The text is replaced |
text: null |
The text is cleared |
No text |
The stored text stays |
Several set calls on one level in one handler combine into one write. For the same key, the last call wins.
set throws at once on a patch it cannot store: an empty key, a number that is not finite such as NaN, or an empty
text. Pass null to clear the text.
What each kind of code can do
Section titled “What each kind of code can do”How your code reaches a visitor depends on where it runs.
- An event handler always runs for a visitor, and it already has their context.
- An rpc function or a workflow takes its visitor from whatever called or started it. It cannot name a different one.
When there is no visitor, for example a workflow started from a schedule, reading or writing either level throws. Check
ctx.userin an rpc function andctx.userIdin a workflow first. Each isnullwhen there is no visitor. - An endpoint, a schedule or a queue consumer names a visitor, using an
anon_…id that you stored earlier or looked up.
| Where your code runs | Read | Write |
|---|---|---|
onEvent, onConnect, transform |
ctx.context.private.entry, ctx.context.shared.byApp |
ctx.context.private.set(…), shared.set(…) |
| Rpc function, called for a visitor | await ctx.context.private.load(), shared.load() |
set(…), as in an event handler |
| Workflow, started for a visitor | await ctx.context.private.load(), shared.load() |
await ctx.context.private.set(…) |
| Endpoint, schedule, queue consumer | await ctx.context(anonId).private.load() |
await ctx.context(anonId).private.set(…) |
| Browser code | app.onSharedContext(…), shared level only |
Not available |
| Agent tool, settings panel | Not available | Not available |
In browser code, register a handler. It runs when the page connects and again each time any app changes shared context.
import { defineBrowserApp } from '@blotout/qinto-sdk-app/browser'
export default defineBrowserApp((app) => { app.onSharedContext((context) => { if (context['loyalty']?.kv['tier'] === 'gold') { document.body.classList.add('gold-member') } })})The handler receives an empty object when no app has shared anything yet, so you can tell “nothing shared” from “not loaded”.
Size limits and refused writes
Section titled “Size limits and refused writes”The limit applies to the whole entry after the merge. A small patch can be refused because the stored entry is already close to the limit. When that happens, the whole patch is refused and the stored entry does not change.
The same refusal applies when your app lacks the visitor’s consent, or writes to the shared level without the
sharedContext capability. What you see depends on where you called set.
- In an event handler,
transformor rpc function, nothing throws, because the write happens after your handler returns. The platform drops it and records a warning on the trace. - In a workflow, endpoint, schedule or queue consumer, the awaited
setthrows. Catch the error if your code can continue without the write.
Keep shared entries short. Every app with the capability receives every shared entry, so a large entry costs all of them.
Consent in your code
Section titled “Consent in your code”- Your app needs the visitor’s consent to read or write either level. Without it, your handler sees no context and its writes are dropped.
- A shared entry stops reaching other apps when the app that wrote it leaves the site, drops the
sharedContextcapability, or loses the visitor’s consent. The entry is not deleted. - If your app is in the
marketingorsalesOfDatacategory, it receives no context for events from a browser that sends Global Privacy Control, and that browser does not receive your shared entries.
Across devices
Section titled “Across devices”Context is stored per device. When one person uses a phone and a laptop, your app sees different context on each one, and linking a device to a person copies no context to it. Your app can sync what it needs between devices with the approaches below.
Write to each device
Section titled “Write to each device”If you know the person, look up their devices and write to each one. This needs the identity capability and the
person’s consent for your app. It runs in an endpoint, a schedule or a queue consumer, where ctx.context(anonId) exists.
let cursor: string | undefineddo { const person = await ctx.identity.resolve('ada@example.com', { cursor }) for (const anonId of person.anonIds) { await ctx.context(anonId).shared.set({ kv: { tier: 'gold' } }) }
cursor = person.nextCursor ?? undefined} while (cursor !== undefined)This reaches the devices linked at that moment. A device that links later does not receive the earlier write.
Send it as a trait
Section titled “Send it as a trait”If the value describes the person, store it as a trait. Traits and custom properties converge across devices.
Your app can write the standard fields itself with ctx.identity.upsert: a first name, last name, city, state, zip or
country, together with at least one of an email, phone or custom id. This needs the identity capability.
await ctx.identity.upsert({ email: 'ada@example.com', city: 'Ljubljana' })Your app cannot write a custom property. The fields above are the only ones upsert accepts, and the events an app emits
are always track events. Custom properties come from your site or backend, through identify. See
User data.
Where to put a value
Section titled “Where to put a value”| The value | Put it in |
|---|---|
| Is small, for your app only, and about one browser | Private context |
| Is small and useful to other apps on the site | Shared context |
| Must drive the page in the browser | Shared context, read with onSharedContext |
| Must be the same on all of a person’s devices | Context, written to each device |
| Is a standard trait, such as a name or a city | A trait, written with ctx.identity.upsert |
| Describes the person, and only your site knows it | A custom property, sent with identify |
| Is large, or you need its history | Your own resource |
Sites that share a graph
Section titled “Sites that share a graph”Under a storage link, sites of one team share each visitor profile. If your app runs on both sites, it reads one private entry for that visitor, because an entry belongs to the app. Each site’s apps read shared entries only from apps installed on that site.