sanqian's SDK started with one integration primitive: Tool. Apps register a few functions, the agent calls them when it needs to. That covered most early integrations.
Then we connected sanqian-notes, and a pattern kept failing. A user highlights a paragraph in the editor and asks "rewrite this." The agent doesn't know which paragraph "this" is. It doesn't know the note's title, the cursor position, or which notebook the note belongs to. All of that lives in the app, not in sanqian.
The obvious fix is a tool — get_app_state() — that returns the editor state on demand. But then the agent has to call it at the top of every turn just to know what the user is looking at. Extra round trip, extra decision overhead, and the model regularly forgets. The other obvious fix is to stuff the state into the system prompt. But system prompts are set at agent-construction time, can't change at runtime, and don't compose when multiple apps are connected.
We needed something else: the app declares it, sanqian injects it. Not a tool. Not a system prompt. That's Context.
This post is about how that mechanism came together.
How the industry approaches it
The closest precedent is MCP's Resources primitive — a pull-based resource interface, sitting alongside Tools and Prompts in the spec. The direction is right, but the spec is silent on which resources should be auto-injected into a conversation. Each host decides on its own. Continue.dev has a feature literally called Context Providers — @codebase, @file, @docs, @terminal, and custom ones — but they're internal to its IDE extension, with no cross-app protocol. Cursor's @-mentions are the same idea, closed. Further afield, Claude Projects and ChatGPT Custom Instructions let users pre-upload files or write a paragraph: static prompt extensions, no runtime state.
We wanted a cross-app, cross-agent protocol with explicit lifecycle semantics. So we built one.
Context vs Tool
The first distinction to nail down.
| Tool | Context | |
|---|---|---|
| Who decides when it's used | the agent invokes it | sanqian injects it before each turn |
| Data path | call → tool result → prompt | straight into the prompt |
| Right for | "fetch X when I need it" | "I should always know X" |
| Failure mode | agent forgets to call, or calls with bad args | over-injection, token waste |
The same underlying data can be exposed both ways. In sanqian-notes, get_note(id) is a tool — the agent calls it when it wants to read a specific note. editor-state is a context — it always injects "what's currently open, where the cursor is, what's selected." They don't overlap, because they answer different questions: "let me go look at X" versus "tell me where the user is right now."
Conflating the two is the most common early mistake. Make "current selection" a tool, and the agent has to call it every turn just to learn what the user picked — that's a context dressed as a tool. Go the other way and inject the whole note library as context instead of using a tool to search it, and the prompt overflows. Drawing this line first makes everything downstream easier.
Context is part of the agent's identity
The second decision, and the deeper one: contexts are bound to agents.
When an app registers an agent through the SDK, it declares which context providers come with it:
await sdk.createAgent({
agent_id: 'assistant',
name: 'Notes Assistant',
system_prompt: '...',
tools: ['get_app_state', 'list_notes', 'search_notes', 'get_note', ...],
attached_contexts: [
'sanqian-notes:editor-state',
'sanqian-notes:notes',
],
})When the user picks that agent, those contexts get injected on every turn. Switch agents, and the contexts switch with them.
What this means: a sanqian agent isn't just a system prompt plus tools. It also includes which apps' state it sees. The notes assistant is the notes assistant not only because its prompt is about notes, but because it declares sanqian-notes:editor-state as a default context — the moment the user switches to it, the notes app's live state plugs in. Tools define what the agent can do; contexts define what the agent can see. Both define who the agent is.
In practice, switching agents means switching contexts. A user might have a notes assistant, a calendar assistant, and a web-reading assistant — each one wired to the relevant app's live state, none of them bleeding into the others.
A nice side effect: Session Resources — the push channel apps can use to shove state into a conversation directly — is also agent-scoped. The default allowlist is the set of apps named in the agent's attached_contexts; selecting an app in the conversation extends the allowlist for that conversation. An SDK app can't push state into an agent that doesn't acknowledge it.
The three layers
With Context defined and bound to agents, the system falls into three layers.
App side contexts: [{ id, getCurrent, getList, getById }]
│
▼
sanqian registry + version diffing + injection
│
▼
Consumer agent config (primary) + runtime attachmentsEach layer minds its own business: the app doesn't care how sanqian injects, sanqian doesn't care where the data comes from, and the agent config doesn't care which app owns the provider.
Provider: three methods
A provider can implement up to three methods, all optional:
interface ContextProvider {
id: string
name: string
description: string
getCurrent?(): Promise<ContextData | null> // "what's happening now"
getList?(options): Promise<ContextListItem[]> // "what could I pick"
getById?(id): Promise<ContextData | null> // "give me this one"
}The three methods map to three UI affordances. getCurrent provides a "live" state — sanqian calls it right before each turn to grab the latest value. getList returns a browsable resource catalog, surfaced when the user opens the + menu in the chat input. getById fetches the full content of a specific item the user picked from that list.
Providers implement what they need. editor-state only implements getCurrent, because "the current editor" isn't something you browse. notes implements all three: "what's open" + "what's available" + "this specific one."
Injection: how it works
The app returns a ContextData — a plain object with content, title, version, and a few other fields. It doesn't deal with prompt formatting. Instead, sanqian wraps the content in <system_reminder> tags and appends it to the last HumanMessage, alongside other dynamic reminders (uploaded files, tool hints, and so on):
human_message = user_input
human_message += build_dynamic_reminder(tools, files, ...)
human_message += process_attached_contexts(state, contexts)Injection happens at message-send time, not at system-prompt time. The agent's base persona stays stable; the context layer rides along with each turn.
Consumer: the agent is primary
The agent's declared attached_contexts is the main binding. The user can attach more at runtime — picking a specific note from the + menu, dragging a selection in — which flows in via load_items messages, merges into conversation state, and rides along with the LangGraph checkpoint.
The planner merges the two sources before each turn:
user_attached = state.get("attached_contexts") or [] # runtime additions
agent_default = agent_config.attached_contexts or [] # declared default
attached_contexts = dedup(user_attached + agent_default)The agent declaration is the spine. Runtime attachments stack on top. Change agents, you change the spine; attach a context mid-conversation, it layers on without disturbing it.
Pull or push
We deliberated between pull (sanqian calls the provider) and push (the app sends state up). Pull won as the default, for three reasons:
- The consumer knows the timing. Context is needed right before a turn fires, but the app can't predict when. Letting sanqian make the call at that moment is the cleanest fit.
- You don't pay for state nobody uses. Push means the app constantly broadcasts state, but most conversations never need most of it.
- Authority lives in the app. Pull guarantees the snapshot is "as of the call," with no cache-coherence headache.
There's one scenario where pull is the wrong shape: the user highlights a passage in notes and clicks "Ask AI" — at that instant, the app knows what should travel along, and sanqian doesn't. For that we kept a push side door called Session Resources:
await sdk.pushResource({
title: 'Current Selection',
content: '<selection>...</selection>',
type: 'note',
})The app pushes, sanqian holds it for the session, the UI shows it, the user can remove it, and it clears when the session ends. Push complements pull. It doesn't replace it.
Tracking state changes
Across multiple turns, a context might have changed since last injection — or might not have. Re-inject every turn and you waste tokens; the model can also get confused by seeing the same payload twice. Never re-inject and the agent runs on stale state.
The mechanism: each context gets a version. The app can supply it (e.g. note.updated_at), or sanqian computes sha256(content)[:16] automatically. Last-seen versions live in AppState.context_versions, and each turn does a three-way diff:
for each ctx in attached_contexts:
version = ctx.version or sha256(ctx.content)[:16]
last = state.context_versions[ctx.provider_id]
if last is None:
inject "[Context: {title}]\n{content}" # first time
elif version != last:
inject "[Context updated: {title}]\n{content}" # changed
# else: skip, agent already has it
state.context_versions[ctx.provider_id] = version
for each provider_id seen before but no longer attached:
inject "[Context removed: {id}]"
delete state.context_versions[provider_id]context_versions rides with AppState through LangGraph's checkpoint, so a reconnecting conversation picks up where it left off.
The token savings are real, but the bigger win is signal. "Context updated" and "Context removed" markers give the model an explicit cue that the user has shifted focus — far more predictable than silently swapping content.
What sanqian-notes wires up
In the notes app, three providers (simplified — real handlers are longer):
function buildContextProviders(): AppContextProvider[] {
return [
{
id: 'editor-state',
name: 'Editor State',
description: 'Current note, cursor position, and selection',
getCurrent: async () =>
buildEditorStateContextFromUserContext(getRawUserContext()),
// no getList / getById
},
{
id: 'notes',
name: 'Notes',
description: 'Browse and attach notes',
getCurrent: async () => buildNotesOverviewContextAsync(...),
getList: async (opts) => { /* merge internal + local notes, paginate */ },
getById: async (id) => resolveNoteResourceAsync(id),
},
{
id: 'notebooks',
// same shape
},
]
}editor-state is the cursor-follower — getCurrent only. notes and notebooks are resource pools — all three methods.
Then agent registration wires specific providers to specific agents:
await sdk.createAgent({
agent_id: 'assistant',
tools: ['get_app_state', 'list_notes', 'search_notes', 'get_note', ...],
attached_contexts: [
'sanqian-notes:editor-state',
'sanqian-notes:notes',
],
})Providers register capabilities. Agents pick which to use. Both live on the same SDK surface.
The integration code isn't long. The hard part isn't writing the providers — it's drawing the line between Context and Tool first, then deciding what each agent acknowledges. Once the protocol is clean, a new app's integration is a few dozen declarative lines, and a new agent's personality is a few lines of attached_contexts.
Two cuts mattered, in order. Separating Context from Tool was the first. Binding Context to the agent was the second. Everything else grew out of those.