@silverprotocol/core
v0.12.11
Published
AgJSON core — the typed schema (AgInput / AgEvent / AgMessage), the Normalizer interface, and the normative reduce().
Readme
Every agent framework — Claude Agent SDK, OpenAI Agents SDK, Google ADK, LangGraph, Vercel AI SDK — streams its own shape of events. Build a client, a UI, or a tool that works across more than one and you end up writing a bespoke adapter per framework.
AgJSON is the wire format that ends that. It normalizes any framework's native event stream into one typed, versioned, forward-compatible shape — so a client written once works with every framework a normalizer exists for.
What's in core
- The schema —
AgInput,AgEvent,AgMessage,AgReduceResult, all as discriminated unions (typed, neverany) with Zod validators. - The
Normalizercontract — the stateful, per-invokepush(native): AgEvent[]/flush(): AgEvent[]interface every@silverprotocol/<framework>package implements. reduce()— the normative fold from anAgEventstream into the messages / turns / artifacts object graph your client renders.
Install
npm install @silverprotocol/coreTo actually produce AgJSON from a framework, add its normalizer:
npm install @silverprotocol/claude-agent-sdk
# or @silverprotocol/openai-agents / @silverprotocol/google-adkConsume AgJSON
core is the consumer side — validate an incoming event stream and fold it into
the object graph, with the same code regardless of which framework produced it:
import { ingestAgEvents, Reducer, AGJSON_VERSION } from "@silverprotocol/core";
const events = ingestAgEvents(rawWireObjects, {
onReject: ({ input, reason }) => console.warn("not an AgJSON event:", reason, input),
});
const reducer = new Reducer();
for (const ev of events) reducer.push(ev);
const { messages, turns, artifacts, memory } = reducer.result();
console.log(AGJSON_VERSION, messages);ingestAgEvents validates raw wire objects against AgEvent and never throws
(the consumer posture). Unknown fields pass through at every depth. An event it
cannot validate (a newer event type, an unknown enum value, a malformed known
type) is not folded: it rides in place as {type: "ext.agjson.ignored", seq,
ignoredType, raw}, which keeps its seq slot so the Reducer never mistakes it
for a gap. raw is for live inspection only; don't persist it. Input that is not
an event at all (not an object, no string type, no number seq) returns
nothing and goes to the optional onReject callback. Reducer folds the
validated stream into the normative snapshot; AGJSON_VERSION is the wire
version this build implements (1.0.0-draft.8).
Stored records and inputs
Messages and memory records you read back from storage, and inputs a client sends, follow the same forward-compatible posture (AgJSON draft.4 §0.2):
import { readStoredAgMessages, readStoredAgMemoryRecords, checkAgInput } from "@silverprotocol/core";
const { value: messages, reports } = readStoredAgMessages(rowsFromYourStore);
for (const r of reports) console.warn("unreadable", r.path, r.ignoredType, r.raw);
const checked = checkAgInput(requestBody);
if (!checked.ok) return reply(400, { code: checked.code, path: checked.path }); // "unknown-value" | "malformed" | "major-mismatch"readStoredAgMessage(s)andreadStoredAgMemoryRecordsomit what they cannot read and report it: acontentelement that fails at any depth is omitted whole, and a record that fails is omitted from its array. Each report carries itspath, the element'stypewhen it has one, and the verbatim value, so inserting every report back at its index rebuilds the stored record. Unknown fields pass through at every depth; nothing is coerced. What they return is a view: keep storing and forwarding the record as received, never the view. A record carried inside an event keeps the event rule above: amessages.snapshotwith one unknown block is ignored whole, so it cannot resync a parked live fold, while the same message read from storage keeps its readable blocks.checkAgInputrejects the whole input, before you act on any of it, when it fails the schema in any way other than an unknown field, and names one path and one class.unknown-value: a string outside a closed set this version defines (akind, an answerstatus, a reasoningeffort, a blocktypeinmessages,run.system,run.contextorresults[].content, …).major-mismatch: another majorversion.malformed: everything else, including a missing value, a wrong JSON type, a non-object input, or aprotocolother than"agjson". It judgesprotocolfirst, thenversion, then the rest; it checks the envelope members whatever thekind, and when the rest has both kinds of problem the class ismalformed. An accepted input comes back with its unknown fields intact.validateHitlAnswerrejects an answer whosestatusis not a defined one (unknown-status), so an unrecognized status is never read as a grant.
Persisting a fold
A turn record's displayRequired[] carries grounding UI that the provider
requires a display to render (for example Google Search Suggestions), and the
provider's terms may not allow storing it. Persist toPersistable(result), the
same fold with every turn record's displayRequired omitted and nothing else
changed, unless the grounding provider's terms permit storing
displayRequired (SPEC §13.3, "Grounding records and the host"). Those terms
can also limit how long a turn's sources[] and grounded content are kept and
who they are re-displayed to; toPersistable leaves them in the fold, so apply
the terms to them yourself.
import { toPersistable } from "@silverprotocol/core";
await store.save(toPersistable(reducer.result()));Memory records follow the same rule. A memory.write of scope agent, user
or skill records a write to the producer's own cross-thread store, not a
promise that you keep it. toPersistable keeps scope thread memory and
omits every other record unless you declare the non-thread scopes you persist
(SPEC §8.0 host obligation 7), and each omission is reported to you by
toPersistableWithReport; toPersistable returns the projection alone:
import { toPersistableWithReport } from "@silverprotocol/core";
const { result, omitted } = toPersistableWithReport(reducer.result(), { memoryScopes: ["user"] });
for (const o of omitted) log.info("memory not persisted", o.scope, o.key);
await store.save(result);If you emit agent.capabilities, declare the same list there as
memoryScopes.
A memory record carries the _meta of the write that set it. A value write
replaces the record whole, _meta included, and a patch replaces _meta
only when the write carries one. A member of the write that the record does
not declare is not carried.
Reducer.push keeps its own copy of each event, and result() and
toPersistable return a new copy on every call, so a host that mutates an event
after pushing it, or mutates a result, never changes the fold. Core makes these
copies itself and does not need a global structuredClone. One exception: a
Date, Map, Set, RegExp, Error, ArrayBuffer, typed array or boxed primitive that
a host places in an event it pushes is not copied. The fold, every result()
and toPersistable share that object with the host's event, so a host that
mutates it after push() sees the change in the fold. No AgJSON value holds
one: an event read from the wire or emitted by a normalizer never does.
Produce AgJSON
Turn a framework's native stream into AgJSON with its normalizer — the output is
framework-neutral, ready to send over the wire, persist, or feed straight to the
Reducer above:
// `query` is the Claude Agent SDK's own streaming call — you keep using
// your framework as-is; the normalizer just translates what it emits.
import { query } from "@anthropic-ai/claude-agent-sdk";
import { createClaudeNormalizer } from "@silverprotocol/claude-agent-sdk";
const n = createClaudeNormalizer();
const agEvents = [];
for await (const native of query({ prompt: "call the echo tool" }))
agEvents.push(...n.push(native)); // one native event → 0+ AgEvents
agEvents.push(...n.flush()); // seal anything still openAPI stability
Symbols tagged @beta in the type declarations are outside the 1.x promise:
they may change in a minor release. Two kinds are tagged:
- In
core, the producer helpers for writing a normalizer:StreamAssemblerand its types (AssemblerCheckpoint,SeqlessEvent, the*Fieldstypes),withAtomicPushwith its options and its error pair (NORMALIZER_ERROR_MESSAGE,normalizerErrorCode), and the JSON-safety helpers with their issue types and markers (toJsonValue*,isJsonValue,JsonSafeIssue*,JSON_SAFE_*). - In the framework packages, an exported type that restates the upstream
SDK's native shapes. It follows the package's peer range. Each package's
own API (its
createXNormalizer(), the options it takes and its helpers) carries the 1.x promise.
Learn more
- Spec & docs — silverprotocol.io/AgJSON
- Flagship, examples & discussion — github.com/silverprotocol/AgJSON
- This SDK — github.com/silverprotocol/typescript-sdk
License
MIT
