@oreoasis/sdk
v0.1.0
Published
Oreoasis SDK — sign an AI agent's action locally and get a signed, publicly-verifiable receipt.
Downloads
84
Maintainers
Readme
@oreoasis/sdk
Your agent did the work. Prove it. Sign an AI agent's action locally and get a signed, publicly-verifiable receipt — a link anyone can open and check without trusting your backend (or ours).
- 🔏 Local signing — the receipt is signed in your process with your agent's
Ed25519 key (via
@kashscript/attest). Inputs/outputs are hashed client-side; raw content never leaves your machine. - 🧩 One line —
client.wrap(type, fn)records a receipt around any function. - 🎯 Typed errors — every failure is a specific class (
OreoasisRateLimitError,OreoasisPlanLimitError, …) carrying the servercode+traceId. - 🔁 Idempotent + resilient — transient network failures resend the identical signed envelope; the server dedups a retry that already landed.
Verification is free forever. Python SDK is a post-launch fast-follow.
Install
bun add @oreoasis/sdk # or: npm i @oreoasis/sdkQuickstart (under 5 minutes)
import { OreoasisClient, generateAgentKey } from "@oreoasis/sdk";
// 1. An agent signing identity. PERSIST IT — see "Your agent's key" below.
// A key minted fresh on every boot is a NEW agent every restart.
const agent = await loadOrCreateAgentKey("my-bot");
// 2. A client (get your API key at oreoasis.com).
const client = new OreoasisClient({ apiKey: process.env.OREOASIS_API_KEY!, agent });
// 3. Record an action → get a public verify URL.
const receipt = await client.record({
action: { type: "tool.call", tool: "search_flights", summary: "Booked SEA→LHR" },
inputs: [{ name: "query", content: "SEA to LHR, 2 pax" }], // hashed locally
outputs: [{ name: "result", content: "PNR ABC123" }],
status: "completed",
});
console.log(receipt.verifyUrl); // → https://oreoasis.com/verify/receipt/rcpt_… (open it!)Your agent's key — persist it
generateAgentKey() mints a new identity. Call it on every boot and every
restart is a different agent: your receipts stay verifiable (the public key travels
inside the envelope, so nothing you already issued is affected) but the continuity
is gone — nobody can say "these 40,000 receipts are all from the same agent."
Persist the key. Ephemeral is the exception, for a one-off script or a test.
import { readFile, writeFile } from "node:fs/promises";
import { generateAgentKey, agentKeyFromSecret, exportAgentSecret } from "@oreoasis/sdk";
async function loadOrCreateAgentKey(name: string) {
const path = process.env.AGENT_KEY_PATH ?? "./.agent-key";
try {
return agentKeyFromSecret(name, (await readFile(path, "utf8")).trim());
} catch {
const agent = await generateAgentKey(name);
// Treat this like any other secret: 0600, outside the repo, in your secret
// store in production. Anyone holding it can sign receipts as your agent.
await writeFile(path, exportAgentSecret(agent), { mode: 0o600 });
return agent;
}
}Losing the key is survivable — mint a new one and carry on; old receipts keep verifying under the old public key forever. What you cannot get back is the claim that both sets came from the same agent.
One-line middleware
Wrap any async function — every call emits a receipt (completed or failed), and the wrapped result/exception passes straight through:
const search = client.wrap("tool.call", searchFlights, {
summary: (q) => `search: ${q}`,
inputs: (q) => [{ name: "query", content: q }],
outputs: (r) => [{ name: "result", content: JSON.stringify(r) }],
});
await search("SEA→LHR"); // runs searchFlights AND records a signed receiptWhat wrap records, precisely — worth reading once, because the answers are
deliberate:
- One receipt per ATTEMPT, not per logical operation. If your caller retries a
failed call three times you get three receipts: two
failed, onecompleted. That is correct — each attempt is an action that happened, and a record that hid the failures would be a worse record. If you want one receipt for the whole operation, wrap the retry loop rather than the function inside it. - A throw records
status: "failed"and re-throws. Your control flow is unchanged; the receipt is a side effect that never swallows an error. - Non-
Errorthrows (a string, an object,undefined— legal in JS) are coerced withString(err)intometa.error. You get"undefined"rather than a crash inside the receipt path. - Receipt failures never break the wrapped call. If recording fails (network,
rate limit, plan cap), the wrapped function's result still returns; route the
error via
opts.onErrorif you want to know. - The return value is hashed only if you ask.
outputs(result)is your function — whatever it returns is what gets hashed. If your function returns a stream or an AsyncIterator, do not pass it tooutputs: hashing it would consume it, andJSON.stringifyof a stream is{}, which hashes to a meaningless constant. Either buffer it first and hash the buffer, or record the receipt after the stream completes with a hash of what you actually sent.
Typed errors
import {
OreoasisRateLimitError,
OreoasisPlanLimitError,
OreoasisClockSkewError,
} from "@oreoasis/sdk";
try {
await client.record({ action: { type: "x", summary: "y" } });
} catch (err) {
if (err instanceof OreoasisRateLimitError) await sleep(err.retryAfterSec * 1000);
else if (err instanceof OreoasisPlanLimitError) console.warn("upgrade your plan");
else if (err instanceof OreoasisClockSkewError) console.error("this machine's clock is wrong");
else throw err; // err.code + err.traceId are always available
}Time: what a receipt proves, and what it doesn't
A receipt carries three times, and they are not equally trustworthy:
| Time | Set by | Means |
|---|---|---|
| timestamp in the claim — "claimed by agent" | you (the SDK defaults it to Date.now()) | what your agent said the time was. It is inside the signed payload, so it proves what was signed — never when it happened. |
| "received by Oreoasis" | the server | when we received and accepted the receipt. Ours to attest. |
| "anchored" | the server | when the chain tip carrying it was published to a public transparency log. |
The verify page shows all three, labelled, and says which two Oreoasis vouches for. Design your evidence around received and anchored; treat the claimed time as a helpful annotation.
Past timestamps are accepted — backfilling a day of history is legitimate and
supported. A timestamp more than 24 hours in the future of server time is
refused with 422 CLAIM_TIMESTAMP_IN_FUTURE (OreoasisClockSkewError), because
that is a broken clock or an attempt to pre-date evidence. Operators can widen or
narrow the window with OREOASIS_CLAIM_FUTURE_SKEW_MS.
prev is your assertion of a predecessor receipt. Nothing validates it — it
may point at a receipt that doesn't exist — so the verify page labels it
"agent-claimed predecessor" and never links it. Oreoasis's own chain link
(chain.prevHash) is separate, server-built and org-signed.
Anchoring: what to expect
Free-tier receipts anchor in batches, typically within a few hours — until then the verify page honestly says "anchoring pending". Paid plans anchor each receipt immediately. A receipt is signed and chained the moment it is accepted; anchoring adds the public existence-proof on top, and its absence never means the receipt is invalid.
⚠️ Never put personal data in meta
Read this once, properly — it is the only field where a mistake is permanent.
Everything you pass as inputs / outputs is hashed locally: the raw
content never leaves your process. meta is the exception. It is free-form,
and it ships verbatim into:
- the signed payload — so it is inside the thing you are asking people to trust, and changing it later invalidates the signature;
- the public verify page — anyone with the link reads it;
- the hash chain, whose tip is published to a public transparency log.
Transparency logs cannot be erased. Not by you, not by us, not by a court order to us — that permanence is the entire point of anchoring, and it applies to your mistakes as faithfully as to your evidence. We can withdraw a payload from display on request (see the takedown policy), but the hash and the log entry stay, forever.
So: no names, no emails, no card or account numbers, no free-text a customer
wrote, nothing you would not print on a postcard. Use meta for the boring
annotations it is for — a workflow id, an attempt number, an internal reference:
meta: { workflow: "refunds", attempt: 2, region: "eu-west" } // fine
meta: { customerEmail: "…", note: userInput } // NEVERSize limit: meta is capped at 8 KiB of JSON. Over that the ingest
refuses the receipt with 422 META_TOO_LARGE — a deliberately loud failure,
because the alternative is discovering it after it is anchored. Large content
belongs in inputs/outputs as a hash.
Hashing notes
- Empty content hashes fine, and means nothing.
sha256("")is a perfectly valid digest (e3b0c442…) and it is the same digest for every empty input, so it proves only "there was a field here". If content is genuinely absent, omit the ref rather than passing"". If it is merely large or already hashed, passsha256directly instead ofcontent. - Large content (>10 MB): pass a precomputed
sha256. The SDK hashes with WebCrypto over an in-memory buffer; on an edge runtime or a small container, hashing a very large blob is the thing that will fall over, and it is work you have usually already done upstream. Uint8Arrayandstringboth work — strings are hashed as UTF-8. The two are not interchangeable:"41"andUint8Array([0x41])are different bytes and hash differently. Pick one representation per field and stay with it.- Duplicate
names ininputsare allowed and preserved in order. Nothing de-duplicates them; a reader sees exactly what you sent.
Idempotency
Pass an idempotencyKey for at-least-once safety — a retry that already landed
replays the original receipt instead of creating a duplicate:
await client.record(action, { idempotencyKey: order.id });Conformance
Receipts are DSSE envelopes with payloadType
application/vnd.oreoasis.agent-receipt+json (registered in the KashScript DST
registry) over a JCS-canonical claim. Published test vectors live in
vectors/agent-receipt-v1.json; any conformant
signer reproduces them byte-for-byte.
