Context assembly
Most chat apps accumulate an array of messages and hope it fits. superchat derives the context every turn from named sources under an explicit token budget, and records why each layer was included, truncated or dropped.
The failure this prevents is specific and common: history grows, silently starves the system prompt, and the model "forgets" its rules. It reads like a prompt problem. It isn't.
Live panel:
/skillsshows the context a query assembles, layer by layer.
Build one
import { ContextBuilder } from "@supersolutions/super-chat-core";
const contextBuilder = new ContextBuilder({
identity: "You are a support agent for Acme. Today is 20 August 2026.",
skills, // a SkillRegistry
contextWindow: 128_000,
maxOutputTokens: 4_000,
keepRecent: 8, // verbatim turns kept at the tail when summarizing
sources: [/* … */],
});
identity is always pinned. That is deliberate: identity is not negotiable
under budget pressure, so it is never the layer that gets dropped to make room.
Read the trace
const context = await contextBuilder.build({ messages });
context.system // the assembled system prompt
context.messages // history after compaction/trimming
context.toolNames // tools allowed this turn, after skill unlocks
context.trace.entries // [{ id, kind, status, tokens, detail, ms }, …]
context.trace.totals // { system, history, tools, total }
context.trace.headroom // tokens left for the reply — negative means over-packed
context.trace.warnings
Each entry carries a status of included, truncated, dropped, compacted
or failed, plus originalTokens when something was cut down. "Why did the agent
know that" becomes a thing you read rather than a thing you guess.
In React you don't have to build this yourself — the runtime emits it:
import { ContextInspector } from "@supersolutions/super-chat-ui";
<ContextInspector /> // renders the trace from the current run
Or headlessly, from the event stream:
for await (const event of runAgent(messages, config)) {
if (event.type === "context-built") {
console.table(event.trace.entries);
if (event.trace.headroom < 0) console.warn("over-packed", event.trace.warnings);
}
}
Sources
A source is a named contributor. Sources run in parallel, so one slow retrieval doesn't serialize behind another.
export type ContextSource = {
id: string;
run(input: ContextSourceInput): Promise<ContextSourceResult> | ContextSourceResult;
};
input carries messages, query (the latest user text — what memory and
retrieval match against), vars, and a signal. The result contributes layers,
and may also unlock tools — which is how a matched skill enables its own tools.
Three come built in:
import {
createMemorySource, createLocalMemoryStore,
createRetrievalSource, createKeywordRetriever,
} from "@supersolutions/super-chat-core";
const memoryStore = createLocalMemoryStore("myapp:memory");
sources: [
// Cross-thread memory, read half. The `remember` tool is the write half.
createMemorySource(memoryStore),
// Retrieval: cited evidence. createKeywordRetriever is a lexical reference
// impl — swap in your own vector store behind the same Retriever interface.
createRetrievalSource(createKeywordRetriever(DOCS), { limit: 3 }),
// App state: lets the agent read the host UI it is embedded in.
createAppStateSource(myAppState),
]
Writing your own is the whole extension point:
import { estimateTokens, type ContextSource } from "@supersolutions/super-chat-core";
const ticketSource: ContextSource = {
id: "open-tickets",
async run({ vars }) {
const tickets = await db.openTicketsFor(vars.userId as string);
if (!tickets.length) return {}; // contribute nothing, cost nothing
const text = tickets.map((t) => `- ${t.id}: ${t.subject}`).join("
");
return {
layers: [{
id: "open-tickets",
kind: "environment", // identity | skills | skill-index | memory | environment | custom
text,
tokens: estimateTokens(text), // you declare the cost; the budget spends it
priority: 400, // higher survives budget pressure longer
}],
};
},
};
Returning {} is the right move when there's nothing to say. A source that always
contributes is a source that always costs tokens.
Compaction
When history exceeds its share of the budget, older turns are summarized with a
cheap model and recent ones (keepRecent) are kept verbatim.
new ContextBuilder({
summarizer: async (messages) => callCheapModel(buildSummaryPrompt(messages)),
keepRecent: 8,
});
If the summarizer fails, compaction falls back to trimming. A summarizer outage costs context quality; it never costs the user their turn. That fallback is not configurable on purpose.
Without a summarizer, history is trimmed at safe cut points — safeCutIndex
never splits a tool call from its result, because a dangling function_call with
no output 400s the entire thread permanently.
Card placement
When any card-capable tool is present, the builder appends a placement note to
the instructions. Without it, models narrate "as you can see in the table below"
above a card that renders above the text. Override it with cardPlacementNote
if your surface puts cards somewhere else.
Next
- What feeds the
skillsoption → Skills - What
toolNamesresolves against → Tools