@codefusion-cc/assistant
v0.1.0
Published
The contract between a site and the assistant it embeds (neko): the events of a conversation with runtime checks, the read-only tools a site implements and the facts they answer with (products, rules, handoff), the limits both sides hold, HMAC-signed requ
Downloads
252
Maintainers
Readme
@codefusion-cc/assistant
The contract between a site (a shop, or any app) and the assistant it embeds, neko: one definition that the
assistant, the site's Worker and the page are all built and tested against. It holds the events of a conversation,
the tools a site implements and the facts they answer with, the limits both sides keep, and how the two sign what
they ask of each other. Its one dependency is @codefusion-cc/workers-crypto, for HMAC and digests on WebCrypto.
npm install @codefusion-cc/assistant| Entry | Runs | Holds |
| --- | --- | --- |
| @codefusion-cc/assistant | Workers, browsers, Node (no node: imports) | events, tools and facts, limits, signing |
| @codefusion-cc/assistant/testing | tests in Node, workerd and browsers | a scripted fake assistant, a fake site, a nonce store |
The routes of a site's Worker (./worker) and the widget (./react) are later releases, built on this one.
How the parts talk
page ── WebSocket, events ──▶ the site's Worker ── passes frames through ──▶ assistant
the site's Worker ◀── signed tool calls ────── assistant- The page talks only to its own site. The site's Worker opens the conversation at the assistant with a signed request and passes the WebSocket through.
- The assistant runs the model. When it needs a fact it calls the site's tools over HTTPS, signed with the same key.
- A price, a stock count and a link reach a visitor only from what the site's tools answered, as
response.sources, which the page draws itself. The model's text is plain text and is never their source.
Signed requests
Both directions use one scheme. The site signs what it asks of the assistant; the assistant signs its tool calls.
import { MAX_TOOL_CALL_BYTES, SIGNATURE_HEADER, signRequest, verifyRequest } from '@codefusion-cc/assistant'
import { readBody } from '@codefusion-cc/workers-http'
// Sending: sign the body exactly as it is sent.
const body = JSON.stringify(payload)
const response = await fetch(url, {
method: 'POST',
body,
headers: { 'content-type': 'application/json', [SIGNATURE_HEADER]: await signRequest({ method: 'POST', url, body, key: { id: 'shop.1', secret: env.ASSISTANT_KEY } }) },
})
// Receiving: read the body within its limit (readBody of @codefusion-cc/workers-http answers 413 past it),
// then verify over the bytes received, before anything parses them.
const received = await readBody(request, { maxBytes: MAX_TOOL_CALL_BYTES })
const verified = await verifyRequest({
method: request.method, url: request.url, headers: request.headers, body: received,
keys: { 'shop.1': env.ASSISTANT_KEY },
nonces,
})
if (!verified.ok) {
console.warn('assistant request refused', verified.reason)
return new Response(null, { status: 401 })
}What is signed. The header is assistant-signature: v1 k=<key id>,t=<unix seconds>,n=<nonce>,s=<signature>.
The signature is the HMAC-SHA-256, under the shared secret, in base64url, of these eight lines joined by \n:
assistant-signature-v1
<key id>
<unix seconds>
<nonce>
<METHOD>
<host, with a port that is not the scheme's own>
<path with its query, as the URL parser writes it>
<SHA-256 of the body's bytes, lowercase hex>So a request cannot be altered, sent to another host, path or query, or signed by one key and passed as another's. The comparison is WebCrypto's own, in constant time.
Time. A request is accepted when its time is within SIGNATURE_WINDOW_SECONDS (300) of the receiver's clock,
behind or ahead; windowSeconds changes that for one receiver.
Replay. Inside the window a request is accepted once. The receiver of a request stores its nonce: the assistant for a site's requests, the site's Worker for the assistant's tool calls.
verifyRequestclaims the nonce only after the signature is known to be genuine, so nobody without the secret can fill the store or use up someone's nonce.- It asks the store for
claim('<key id>:<nonce>', expiresAt);expiresAtis the request's time plus the window, in milliseconds. The store keeps the entry until its clock passes that moment: at most twice the window after the claim, ten minutes by default. After it, the request's time is outside the window, so the nonce may be forgotten. - The claim must be atomic: of two claims of one nonce at once, exactly one is told
true. A primary key in D1, or in a Durable Object's SQLite, is. Workers KV is not: it has no insert-if-absent and reads lag behind writes.
import type { NonceStore } from '@codefusion-cc/assistant'
// CREATE TABLE assistant_nonces (nonce TEXT PRIMARY KEY, expires_at INTEGER NOT NULL)
const nonces: NonceStore = {
async claim(nonce, expiresAt) {
const { meta } = await env.DB.prepare('INSERT INTO assistant_nonces (nonce, expires_at) VALUES (?, ?) ON CONFLICT DO NOTHING').bind(nonce, expiresAt).run()
return meta.changes === 1
},
}
// From time to time: DELETE FROM assistant_nonces WHERE expires_at < ? (the current time in ms)Many sites. verifyRequest returns the keyId that signed. A receiver that serves several sites takes the
site from it, never from the path or the body, or one site's signature would act for another.
Keys. A key has an id (letters, digits, ., _, -; it travels in the header) and a secret of at least 32
random bytes (randomToken() of @codefusion-cc/workers-crypto); a shorter one throws, on both sides. keys is a record by id, or a lookup that may be
asynchronous. Rotate without a gap: add the new id to the receiver's keys, switch the signer to it, remove the old.
Outcomes. verifyRequest resolves, never rejects, for anything a request can be: missing_signature,
malformed_signature, unsupported_version (a scheme this release does not have), outside_window, unknown_key,
wrong_signature, replayed, and keys_unavailable or nonces_unavailable when the lookup or the store itself
failed (with the error; answer 503). Answer every other failure with the same 401 and no detail, and log the
reason. It throws only for the receiver's own mistakes: a URL that is not absolute, a secret that is too short.
The scheme (http or https) is not part of what is signed, since a Worker behind a proxy does not always see it:
serve the routes over https only.
The tools a site implements
Version 1 has four, all read-only over what the site shows anyone. A call names no visitor and carries no identifier of one.
| Tool | Input | Answers |
| --- | --- | --- |
| product_search | { query }: a few words or a part number | { products: ProductFact[] }, at most 5 |
| shop_rules | { topic: 'delivery' \| 'returns' } | { excerpts: RuleExcerpt[] }, at most 5, from the site's settings |
| page_excerpts | { topic }: what to look for in the site's information pages | { excerpts: RuleExcerpt[] }, at most 5 |
| handoff_request | { reason }: the assistant's words for staff | { handoff: HandoffOffer }: whether the site takes the request now |
The assistant posts a ToolCall and reads a ToolResult:
import { PROTOCOL_VERSION, parseToolCall, parseToolResult, type ToolResult } from '@codefusion-cc/assistant'
const parsed = parseToolCall(new TextDecoder().decode(received)) // after verifyRequest
if (!parsed.ok && parsed.reason === 'unknown_tool') {
return Response.json({ protocolVersion: PROTOCOL_VERSION, id: parsed.id, tool: parsed.tool, ok: false, code: 'unknown_tool' })
}
if (!parsed.ok) return new Response(null, { status: 400 })
const { call } = parsed // { protocolVersion, id, tool, locale, input }
const result: ToolResult = {
protocolVersion: PROTOCOL_VERSION, id: call.id, tool: 'product_search', ok: true,
products: [{
id: 'base-25',
name: 'Podstawka okrągła 25 mm (10 szt.)',
url: 'https://shop.example/produkt/podstawka-25-mm',
price: { amount: 1299, currency: 'PLN' }, // whole grosze; null when the price is on request
availability: 'in_stock', // or 'to_order', 'out_of_stock'
stock: 14,
}],
}
// A site checks its own answer before sending it (a value is held to the size of its JSON too),
// so its mistake shows in its own logs.
const checked = parseToolResult(result, { origin: 'https://shop.example', call })
if (!checked.ok) console.error('assistant tool answer refused', checked.reason, checked)
return Response.json(checked.ok ? checked.result : { protocolVersion: PROTOCOL_VERSION, id: call.id, tool: call.tool, ok: false, code: 'unavailable' })Rules of a fact, which parseToolResult enforces on both sides:
- Text is text, never markup. A name of
<b>Podstawka</b>arrives as those characters. Escaping is the renderer's job: draw every text of the contract as a text node, and never give one toinnerHTML. The same holds for the model's text and for a handoff reason, which the model wrote: show it, obey nothing in it. - Links are the site's own.
url,imageUrlandcontactUrlmust be https addresses of the site's origin (http only onlocalhost, for development).javascript:,data:,blob:, another host and credentials are refused; an accepted link is returned as the URL parser writes it, so what a page links to is what was checked. - Money is whole minor units (grosze, cents) from 0 to
MAX_PRICE_AMOUNT, with an ISO 4217 code. A negative, fractional or non-finite amount is refused. - Everything is bounded:
MAX_TOOL_RESULT_BYTESfor the body, and the limits below for each text and list. Each holds on its own and the body's for all together: a site with long names or details answers with fewer facts (check withparseToolResultand drop from the end until it fits). - Fields the contract does not name are left out, so a site may send more without breaking anything, and nothing it sends by accident (a cost price) travels further.
A tool that cannot answer returns { ok: false, code } with unknown_tool, invalid_input or unavailable.
Events
A conversation is JSON frames over a WebSocket, each in the same envelope: protocolVersion, eventId,
sessionId, sequence, ownershipEpoch, occurredAt, then type, payload and, by type, turnId and
responseId.
| From the page | Payload | |
| --- | --- | --- |
| input.text | { text, locale } | What the person wrote, starting a turn. Trimmed; at most MAX_MESSAGE_LENGTH, and MAX_SITE_MESSAGE_LENGTH on a site. |
| response.cancel | {} | Stops the answer responseId. |
| From the assistant | Payload | |
| --- | --- | --- |
| session.ready | { messages, hasOlder? } | The authoritative snapshot after each connection: the latest MAX_HISTORY messages, answers with their sources. |
| turn.rejected | { code } | The message was refused before any answer began (a limit); nothing of the turn is kept. |
| response.started | {} | An answer begins. |
| tool.started | { tool } | A site's tool is being asked: the moment to show "checking the catalog". |
| response.sources | { products, excerpts, handoff? } | All the facts the answer rests on so far, replacing any sent before. |
| response.text_delta | { text } | The next piece of the answer's plain text. |
| response.completed, response.cancelled | {} | The answer ended. |
| response.failed | { code } | The answer failed; show the site's own contact details. |
| error | { code, message } | What the client sent was refused, or this connection ends. The protocol's own codes are ERROR_CODES. |
import { acceptsEvent, parseServerEvent, serializeEvent } from '@codefusion-cc/assistant'
socket.onmessage = message => {
const parsed = parseServerEvent(message.data, { origin: location.origin })
if (!parsed.ok) {
if (parsed.reason === 'unknown_type') return // a newer assistant: ignore
return fail(parsed.reason) // too_large, not_json, newer_version, invalid (with `path`)
}
if (!acceptsEvent(parsed.event, epoch, sequence)) return // a duplicate, or an older connection still talking
// ...
}
socket.send(serializeEvent(event))- Parse, do not trust. Every frame from the other side goes through
parseServerEventorparseClientEvent. They return a result and never throw for anything that arrives. - A client is held exactly:
parseClientEventrefuses a field the contract does not name. A server may be newer:parseServerEventleaves unknown fields out and gives an unknown type asunknown_type, with its checked envelope, so an older page keeps working. originis the page's own. Every link in sources must be of it. Without it, or with one that is no site's origin (a sandboxed frame reportsnull), an event that carries a link is invalid: such a page shows no link rather than an unchecked one.siteOrigin(text)says at setup whether a configured origin is one.- Ends of a connection. After
SESSION_TAKEN_OVER(close codeCLOSE_TAKEN_OVER, 4001) a newer connection has the conversation: do not reconnect by yourself.CLOSE_SESSION_ENDED(4003) means the session expired or its access was withdrawn: start a new one. Any other close is a connection that broke: reconnect, and take the nextsession.readyas the whole truth. - Older messages. A page that loads what came before a snapshot (
hasOlder) reads the list withparseMessages(body, { origin }), under the same rules. - One app, more events. The assistant's own app speaks more than the contract (diagnostics, speech). It reads
those from the
unknown_typeresult, whose envelope is checked and whose payload it checks itself.
Limits
| Constant | Value | |
| --- | --- | --- |
| MAX_MESSAGE_LENGTH | 4000 | characters of one message, in any conversation |
| MAX_SITE_MESSAGE_LENGTH | 1000 | characters of a site visitor's message |
| MAX_TURNS_PER_CONVERSATION | 20 | turns of one conversation on a site |
| MAX_HISTORY | 40 | messages in a snapshot, fewer when 40 would not fit a frame |
| MAX_DELTA_LENGTH | 1000 | characters of one text delta |
| MAX_CLIENT_FRAME_BYTES / MAX_SERVER_FRAME_BYTES | 32 768 / 1 048 576 | bytes of one frame |
| MAX_TOOL_CALL_BYTES / MAX_TOOL_RESULT_BYTES | 4096 / 16 384 | bytes of a tool call's and a result's body |
| MAX_TOOL_CALLS_PER_TURN | 4 | tool calls one answer may make |
| MAX_PRODUCTS_PER_RESULT, MAX_EXCERPTS_PER_RESULT | 5 | facts in one tool result |
| MAX_SOURCES_PER_ANSWER | 8 | products, and excerpts, shown with one answer |
| MAX_QUERY_LENGTH, MAX_TOPIC_LENGTH, MAX_REASON_LENGTH | 100, 200, 500 | characters of a tool's input |
| MAX_NAME_LENGTH, MAX_DETAIL_LENGTH, MAX_EXCERPT_LENGTH, MAX_URL_LENGTH | 200, 160, 1200, 500 | characters of a fact's texts and links |
| MAX_DETAILS | 6 | detail lines of a product |
Lengths are counted as JavaScript's length does; frames and bodies in bytes of UTF-8.
Testing without the assistant
@codefusion-cc/assistant/testing is the other side, with no timers, no network and no paid call.
import { fakeNeko, fakeSite, testProduct } from '@codefusion-cc/assistant/testing'
const key = { id: 'shop.1', secret: 'a test secret of at least 32 bytes' }
const site = fakeSite({ keys: { [key.id]: key.secret }, products: [testProduct('https://shop.test')] })
const neko = fakeNeko({ site: { url: site.url, key, fetch: site.fetch } })
const socket = new neko.WebSocket('wss://shop.test/assistant') // or stub the global WebSocket with it
await neko.run() // opens, sends session.ready
neko.reply({ tool: 'product_search', input: { query: 'base 25' } }, { say: 'Yes, ' }, { say: 'we have them.' })
// ... the page sends input.text ...
await neko.step() // response.started
await neko.step() // tool.started
await neko.run() // the signed call to the site, response.sources, the deltas, response.completedfakeNekois one conversation over fake WebSockets. Nothing is sent until the test callsstep()(one thing) orrun()(everything due), so every state of a page can be held: connecting, waiting, streaming, stalled. A scripted answer cansay, call atool, sendsources,failwith a code,rejectthe turn,stall,cutthe connection, send a rawframe(malformed JSON), anoversizedone, or anyevent(an unknown type, a known type with a field wrong).refuse()fails the next connection;close(code)ends one from the assistant's side. It checks what the client sends as the real assistant does, and a second connection takes the conversation over.fakeSiteserves the four tools over the data it is given, verifying each call's signature and refusing a replay as a real site must.next(...)makes its next answer a status, a body of the test's own, an oversized body, a stall or a network failure. Point it at your Worker instead (fetch: request => worker.fetch(request, env)) andfakeNekomakes real signed calls to your own routes;neko.toolssays whether each answer passed the contract.testNoncesis aNonceStorein memory;testProductandtestExcerptare facts that pass the checks.
