@hachej/boring-agent
v0.4.29
Published
Native Pi attachment, the read/write/edit guard, durable questions, conditional document delivery and metering.
Readme
@hachej/boring-agent
/native re-exports exact Pi handles, configuration, task/view types and native ToolRegistration/ToolExecutionApi/ToolExecutionResult/hooks. Use upstream defineTool, registry installation and runtime values directly. There is no parallel Boring HostOperation/RuntimeSchema/tool engine.
/contracts contains only borrowed attachment and original runtime-question/delivery references. Passive attachment takes the native Harness, not a second possibly unrelated writable Registry. Host subtypes retain their native handle. Domain services keep their own signatures; no file package is required to configure an agent.
attachHarness({ harness }) preserves the exact host handle. It acquires no observers or resources, so detaching is passive and never closes the host. Use native observation APIs directly and release their subscriptions under their own owner.
One file tool set: the guard
The agent edits files with Pi's own read, write, edit and bash over the workspace's ExecutionEnv; Boring adds no replacement file-editing tools. The optional document readers extract PDF and spreadsheet content without replacing native read. createFileGuard({ workspace, resolveAccess?, name? }) from @hachej/boring-agent/file-guard returns a native extension (named name, default boring.files.guard: two agents with different workspaces in one registry need two names, or the last installed guard wraps both) that wraps those three tools with the public wrapTool (select it after the extension that registers them). Every wrapped call runs inside the workspace provider's mutation queue (@hachej/boring-files/workspace), so a viewer save cannot land between edit's internal read and its write. workspace is the call's workspace resolved like Pi's environment, or one binding { files, root, access? } (see Workspace per call); resolveAccess defaults to the binding's access. A genuine read call records the file's revision as the conversation's last-read revision of that workspace and path (baselineKey) in a native conversation document (lastReadRevisions, kept across restarts; a fork starts empty); edit's internal read records nothing. write and edit of a file that exists are refused unless the conversation has read it and its revision still equals the recorded one, and the refusal tells the model to read the file again. Creating a file is allowed only while it is absent. The guard fails closed: an unresolvable workspace, a refused provider read, a path outside the root, a link that leads outside it and a path that does not exist (so Pi's read never falls back to another spelling of it, such as an NFD name) (resolved through the env's canonicalPath, so also a new file under a link directory) are refused for read as well as write and edit; nothing falls through to Pi's native tool. After a successful write or edit the baseline is the revision just written, and the guard calls the provider's record (owner ruling 2: observed, never receipted): the file's history gains the written revision with source agent (and the bytes it replaced, read before the tool ran when that revision is not already retained), and the workspace's change feed an agent event with the conversation. Nothing enters the publication journal. Pi's edit tries an exact match first and falls back to a fuzzy one (Unicode normalisation, trailing whitespace, smart quotes, dashes and special spaces); the guard does not change that. A bash write is not intercepted, but it changes the revision, so the next write or edit of that file is refused until it is read; it reaches the change feed (no history) through the file system's watcher or the host's poll() at turn end. The guard adds no strictness option and no file API of its own.
Workspace per call
@hachej/boring-agent/workspaces resolves the workspace of each call the way Pi resolves its environment, so one harness and one agent definition serve several people, each conversation in its own workspace. A WorkspaceResolver is (target, context) => binding | undefined with Pi's EnvTarget; a binding is { id?, files, root, env?, access? } (files the workspace provider, root as the env names it, env the instance Pi uses, access the agent's principal). The guard, present and host tools (the studio's canvas tools) call workspaceFor(resolver, api, context), which builds the target from the call (callTarget) and refuses a binding whose env is not the call's api.env. constantWorkspace(binding) is the single-workspace case; passing a binding where a resolver is expected wraps it. The first-class way needs no resolver at all: withWorkspace(env, binding) attaches a binding to an env (every createWorkspaceCache entry does it), and a tool given no workspace resolves workspaceOfEnv(api.env), the workspace of the env the host's HarnessOptions.env returned for that call, so the host wires one function. A binding may also carry the workspace's git repository (working_git uses it).
createWorkspaceCache({ key, open, atCwd?, idleMs?, busy?, onError? }) gives a host both sides from one entry per key: env for HarnessOptions.env, workspace for the tools, acquire(key) for viewer and HTTP routes after authentication (the lease's release never closes), keys() and close(). A workspace opens once per key, and closes after idleMs with no lease when busy(key) is not true. rootConversation(session, id, context) follows the owner edge of a task-owned conversation (a subagent's child) to the conversation whose workspace it uses. A CredentialResolver (target, context, { tool, server? }) is the host's per-call secret lookup; tools call it at execute time and keep the value in memory only.
For a conversation with a different working directory, supply atCwd(workspace, cwd, context) to return an independent native environment over the same workspace. The cache retains that view per directory and attaches the original provider and access binding. The workspace owner closes any resources the callback acquires. Without this callback, a different directory is refused. Never mutate the shared environment's cwd or start a second remote workspace to create a directory view.
createHarnessPool({ create, context, idleMs?, onError? }) from @hachej/boring-agent/harness-pool is the alternative: one harness per owner, opened by the host's create(owner) on first use (never shared, concurrent first uses open it once), held by acquire/use, by respond until a streaming response body ends, and closed once idle with no live task or submission. owners() and opened() read what is open without holding it. Choosing between them: FILES-GIT-EXEC.md.
Conversations
@hachej/boring-agent/conversations is a conversation list over native Pi. createConversations({ harness, context, now?, titleLength?, previewLength?, onError? }) keeps one native conversation document per conversation (conversationMetadata, kind boring.conversation.metadata, fork: 'initial'): the host's owner key, a title (set by create/rename, otherwise derived once from the first user message), createdAt, updatedAt (last activity), a bounded lastMessage preview of the last user or assistant text (mention <file> blocks excluded), archived and deletedAt. The metadata of a turn is written after its entries commit: the module observes harness.subscribeCommits (no polling, no transcript scan) and one queued writer applies the latest message of each conversation. Only conversations it created (or adopted) are managed; any other conversation, including a native fork made elsewhere, is never listed.
create(owner, { title?, start? }) writes the metadata in the creating commit (start(init) creates the native conversation with the host's agent, for example definition.createConversation(harness, context, { init })). list({ owner, archived?, query?, limit?, cursor? }) returns the owner's summaries newest activity first; query is a case-insensitive substring of the title or the last message. rename, archive(owner, id, archived), get, open and fork(owner, id, at) (native Conversation.fork, the history up to and including entry at, titled " (fork)") refuse another owner's or a deleted conversation by returning undefined. delete aborts the conversation's ordinary work and sets deletedAt: Pi has no conversation deletion, so the records stay in the session storage and their retention is the host's (prune or drop the storage). adopt(id, owner) manages an existing conversation once (title and preview from a bounded history read), for migrating a host's own index. Owner scoping and authorization stay with the host: createConversationsHandler({ conversations, authenticate }) is a Fetch handler (GET lists with ?archived=1|all&q=&limit=&cursor=; POST ?op=create|rename|archive|delete|fork with a JSON body) whose authenticate(request) returns { owner, start?, opened?, deleted?, allow? } or null.
conversationTitle(conversation, context, cache?) is the title of a conversation that has none stored: its first user message, clipped to 80 characters. The first message never changes, so a non-null title is cached (cache: a Map keyed by the conversation id) and the history is read once; null (no user message yet) is not cached. A host's list poll passes its cache rather than paging the history again.
Listing reads every conversation record (scanConversations) and one metadata document each: Pi's storage has no query over document values or text index, so search is an in-memory substring match. A host with many conversations keeps its own index.
Mentions
createMentionResolver({ read, mode?, limits? }) from @hachej/boring-agent/mentions turns the @path mentions of a submitted message into parts the host adds before submission. The default mode: 'inline' puts the text of each file (and images) in the message; documents (PDF, office, zip, epub) are never decoded as text. With mode: 'reference' each mention is one <file path type size /> part and no content, so uploads do not fill the context: the agent reads the file with its tools (Pi read, which createConvertingRead from @hachej/boring-agent/file-convert extends to PDF, office and images) when it needs to.
Model gateway
@hachej/boring-agent/model-gateway (server only: it holds the provider key) is createModelGateway({ upstream, allow, budget, authorize, log?, basePath? }), a Fetch handler for POST <basePath>/v1/chat/completions (OpenAI-compatible, JSON or SSE passed through) and GET <basePath>/v1/models. authorize(request) returns the person's id from the app session (else 401); allow lists gateway model ids or maps them to upstream ids (else 403); budget.admit refuses (429, Retry-After) or admits with the output ceiling to send (memoryBudget({ requestsPerMinute, maxTokensPerRequest }) is the in-process reference). The forwarded body is rebuilt from an allowlist of fields and no request header is forwarded; the upstream (openAICompatibleUpstream, anthropicUpstream, keyless scriptedUpstream) adds the key. Upstream failures are 502 with the gateway's own message, never the upstream body. log receives person, model, status, code, latency and token counts only. It imports no node or Pi module, so the same code can run as a shared service.
@hachej/boring-agent/gateway-provider is the browser half: gatewayProvider({ baseUrl, models, getAuth }) returns a native Pi provider (Pi's openai-completions API at <baseUrl>/v1) whose only credential is getAuth()'s session headers, read on every request. Use it with createModels().setProvider(...) and any native model call.
Metering
@hachej/boring-agent/metering ports the v2 credits contract onto native Pi. The host owns the ledger, the price and the policy (INVARIANTS: budgets are host data); the module decides only when to call the ledger. Pi has no hook that can hold or refuse a model request (beforeRequest only replaces messages, afterResponse observes), so the fence is submission admission, and usage is read from the durable transcript, not from a second usage store (SPEC "Model admission, usage and settings").
- Scope (host-owned):
MeteringScopeis{ userId, workspaceId?, attributes? }, the host's mapping of its own authentication (attributes: at most 16 flat JSON-safe values such as a plan or tenant, passed through untouched). Every sink call carries the run scope: that plusconversationId,requestIdandrunId. MeteringSink(v2'sAgentMeteringSink):isEnabled?,reserveRun(input)→{ reservationId }or{ kind: 'refused', reason }(a throw also refuses: fail closed),recordUsage(input)→{ billedMicros },settleRun,releaseRun, andopenRuns()for recovery. All idempotent onrunIdandusageId. The reservation also carriesmodel: { provider, id }, the model the run will use (the conversation's selected model resolved through Pi'sConversation.agentat submit time, so a host can admit per model as v2 governance did),kind(input, or the input's nativewhenBusychoicefollowUp/steer), amessagepreview (text, whitespace collapsed, at mostMESSAGE_PREVIEW_LENGTH= 280 characters) and the rawcontent. Usage keeps its own per-entrymodel(a run can switch models).createMeter({ sink, context, price?, models?, markup?, onError? }).meter.conversation(conversation, scope)is the native conversation with an inputsubmitthat reserves first; a refusal throwsMeteringRefusedand nothing is submitted (the chat transport answers it with 402submission-refusedand the ledger's message;@hachej/boring-ui-kit/remote-chatturns that into a blocked send showing the message). The run id isrun:<conversation>:<requestId>, so a client retry reserves nothing new.- Recording: on every conversation change the meter reads each open run's submission record and the entries from its user entry to the next placed input; every assistant entry (and tool result) carrying
usageis recorded once underentry:<conversation>:<entry>(the native attempt identity), pricedprice(usage, model) * markup(defaultcatalogPrice(models): Pi'scalculateCostwith the model's rates, else the provider-reported cost, in micro-dollars). - Termination, when the submission is
doneorunanswered, exactly one call: a usage write still failing charges the hold (usage-write-failed); a person's stop settles what was billed or frees the hold (cancelled); an aborted attempt with no billed usage (interrupted, usage unknown) or a non-zero report priced to nothing charges the hold (fallback-charge); billed usage settles; complete all-zero reports settle at zero; an answer without any usage report charges the hold; no model call at all frees it (no-model-call,error-before-usage,not-started). meter.recover(harness)after a restart: each run the ledger still holds open is finished from Pi's durable state (finished: settle with the replayed usage; interrupted with unknown usage: charge the hold once Pi's recovery ends the run; never submitted: release), and the rest are watched.- Ledgers:
createMemoryLedger(options)andcreateSqliteLedger({ connection, ...options })(a borrowedSqliteConnection) orcreateSqliteLedger({ filename, sqlite?, ...options })(a file the ledger opens withopenNodeConnection(filename, sqlite)and closes onclose();sqliteisSqliteSettingsfrom@hachej/boring-files/sqlite, local disk WAL by default; several processes on one file on a network file system use{ journalMode: 'delete', lockingMode: 'normal' }, one ownersqliteSettings.networkFilesystem). Every ledger write is one write transaction (BEGIN IMMEDIATE), so concurrent writers wait up to the busy timeout instead of failing, and a reservation's balance check and hold commit together (no double spend; four-process test intest/packages/agent.test.mjs). Tablesboring_metering_*with each run's scope, model and kind, charges keyed by (run, step) with the usage's model; tables of the earlier account-keyed shape are refused, not migrated). Options:holdMicros, a number or a function of the reservation (model-aware admission: a costly model holds more, so a low balance refuses it while the default model is still admitted);balanceKey?(scope), the balance a run draws on (default itsuserId;scope => scope.workspaceId ?? scope.userIdpools a workspace);refusal?(available, hold, input). Both addbalance(key)and an idempotentgrant(key, amountMicros, grantId); a reservation is refused when the available balance (grants minus charges minus unspent open holds) is below its hold; a fallback charge tops the run's charges up to its hold.
Limits: a run is admitted against its hold, not stopped mid-run (no per-call fence in Pi), so one run can overdraw into recoverable debt that refuses the next one; usage of child conversations (subagents) and of input submitted without the metered view (channels) is not metered; a sink call that keeps failing is retried on the next change of that conversation or by recover. The browser-agent model gateway keeps its own per-request ModelBudget.admit; a host bills browser runs by calling its ledger from that admit.
Artifacts
createPresentTool({ workspace, resolveAccess?, maxBytes? }) from @hachej/boring-agent/artifacts returns the native present(path) tool (its workspace resolved per call like the guard's): the agent writes a workspace file with its ordinary file tools and presents it, and the chat shows a card that points at that file (target, the revision shown, no id or ordinal). The file's versions are the history of the workspace provider (@hachej/boring-files/workspace, whose keep retains the presented revision); a card of an older revision pins it and a card of the newest follows the file as it changes. The tool writes nothing and refuses files that are missing, not UTF-8 text or larger than 256 KiB. An opt-in ack: { timeoutMs?, viewers? } makes present wait until a page has mounted the presented revision: the page answers { kind: 'opened', instanceId, path, revision } or { kind: 'refused', reason } through the chat's answer (the transport's ?op=answer), and the result carries opened: true with viewer, opened: false with refused (for example closed by user), opened: 'unconfirmed' after timeoutMs (default 15 s) or opened: false, viewer: 'none' at once when viewers() says no page watches the conversation. The wait is the durable wait of ask_user (packages/agent/src/view-open.ts, a conversation document keyed by the call); the deadline is stored, so a restart cannot extend it, and the first valid answer wins. Without ack the result is unchanged. answerViewOpen settles an answer; the host chains it with answerUserQuestion and answerBrowserTask in its answer handler. How to use it is in the tool's own description, so a host does not repeat it in its prompt: substantial output goes to a file that is then presented, short answers (and anything asked for in the chat) stay in the chat, and a revision re-reads the file, changes it at the same path and presents it again. A host adds only what is its own, such as what its HTML preview allows.
A view that is not a document (a dashboard)
A view the agent opens, such as a dashboard, is presented the same way: it is a small workspace file that names what to show (for example sales.dashboard.json), and the app registers an artifact kind that draws it. There is no second channel: the same ack answers for it. Mark the kind waitsForReady: true when its renderer loads asynchronously: the panel then reports the mount (and the page confirms the present call) only after the renderer calls onReady for the revision shown. onFailed(reason), or a renderer that throws, refuses the call with that reason, and a renderer that never becomes ready leaves it unconfirmed at the deadline. A kind without waitsForReady is confirmed as soon as its file is read.
// Server: the agent writes sales.dashboard.json, then calls present('sales.dashboard.json').
createPresentTool({ workspace, ack: { timeoutMs: 15_000 } });
// -> { kind: 'presented', artifact, opened: true, viewer } | { opened: false, refused: 'the sales query failed' } | { opened: 'unconfirmed', timeoutMs }
// Page (pi-app block): the kind confirms the view only once its data has loaded.
const dashboard: ArtifactKind = {
match: target => target.resource.path.endsWith('.dashboard.json'),
label: 'Dashboard',
waitsForReady: true,
renderer: ({ text, onReady, onFailed }) => <SalesDashboard spec={JSON.parse(text)} onLoaded={onReady} onError={error => onFailed?.(error.message)} />,
};
<AgentWorkspace {...workspace} artifactKinds={[dashboard, ...defaultKinds()]} />; // defaultKinds: the pi-app-kinds itemworkspaceMentionReader(files, access, { maxReadBytes? }) is the reader for a host with a workspace provider: it reads the person's own published view through the provider (the access check the file viewers use) and has no containment check of its own. A host that has no provider has no reader here; it writes a MentionReader over whatever it does have.
The descriptor's id and ordinal are optional and present sets neither; parseArtifact validates a descriptor and has no Pi runtime dependency (the copied pi-chat registry item carries the same validator for the browser).
Agent state refresh and converted file text
refreshAgentState({ conversations, agent, read, write, context }) from @hachej/boring-agent/agents brings existing conversations up to a changed deployment. A conversation keeps the agent it was created with, so when the agent's instructions or default thinkingLevel change, the helper calls configure on each conversation. The host stores a hash of [instructions, thinkingLevel] (read() returns the stored hash, write(hash) stores the new one); it configures nothing when the hash is unchanged and writes the new hash last, so a failed run is retried. It returns how many conversations were configured.
Add an extension to an existing conversation
addExtension(conversation, extension, context) from @hachej/boring-agent/agents adds one Pi extension (for example createApprovalExtension(...) or one that registers a tool) to a conversation that already exists; its next turn runs with it. It edits the conversation's stored pi.agent selection in one native Conversation.commit (Pi's public AgentDoc): the name is appended to the conversation's own list, so it keeps its other extensions and picks up no other agent's, and the extension's tools are enabled and offered even when the conversation lists its tools. Adding it twice changes nothing; remove it with native conversation.configure({ extensions: { remove: [extension] } }, context). Native configure({ extensions: { add } }) is not the same: on a defineAgent conversation it replaces the stored list with an edit of the host default selection.
const registry = createRegistry();
agent.install(registry);
registry.install(approval); // at every Harness open: the conversation stores the name only
const conversation = await harness.conversation(id, context);
await addExtension(conversation, approval, context);The selection is durable (it is in the conversation's Pi state and survives a reopen), but an extension is host code and is not stored: install it in the registry every time the Harness opens, or its name selects nothing. Installing it does not add it to other defineAgent conversations, whose lists are explicit; a conversation with no stored list (such as harness.root() without agent) follows the host default selection, which is every installed extension unless HarnessSettings.extensions names them.
createConvertingRead({ workspace?, resolveAccess?, convert?, cache?, maxBytes?, name? }) from @hachej/boring-agent/file-convert returns an extension that wraps Pi's native read (wrapTool); there is one read, and no second tool. Text goes to Pi's read untouched. A PDF, an office document or an image goes to the host's convert({ name, mediaType, bytes }), once per saved revision (cached by path and revision), and the converted text is paged the way Pi pages a text file: offset is the 1-based line to start at, limit a number of lines, a page ends at 2000 lines or 50 KB, and the note names the offset to continue from. What is text and what is not comes from one table, fileKind in @hachej/boring-agent/file-types, which looks at the extension and the leading bytes (Pi's read returns garbled text for a PDF, so a PDF is recognised by %PDF- even under another name); the mention resolver uses the same table. An archive or other binary file answers unsupported and is never converted; without convert, an image uses Pi's native behaviour and a PDF or office file answers unsupported. A line of converted text above 50 KB is split into 50 KB segments, each counted as one line, with a warning, so nothing is cut. The type is sniffed on the bytes the provider returns after its access check, and the converter is told the media type the file's signature names (a PDF named .txt arrives as application/pdf). The file is read through the workspace provider, so its access policy applies; a path the wrapper cannot place inside the workspace is left to the native read. Install it before createFileGuard, so the guard stays outermost and refuses anything outside the workspace first. workersAiMarkdown(env.AI) adapts a Workers AI toMarkdown binding to convert without a Cloudflare types dependency.
Shared document readers
@hachej/boring-agent/documents exports documentTools({ readBytes, ocr?, images? }), sniff(bytes, name?) and readStored(path, bytes, { images }). These are native defineTool registrations and a native read-result helper, not a filesystem or another read tool. Pi's own read is unchanged; file-convert above remains the host-conversion alternative.
import { defineExtension } from '@earendil-works/pi-durable';
import { documentTools, readStored } from '@hachej/boring-agent/documents';
// readBytes is the host's authorized read through its one workspace provider.
const tools = documentTools({ readBytes, ocr });
registry.install(defineExtension({ name: 'app.documents', tools }));
// A host's stored-file read path can return this native ToolExecutionResult:
const result = readStored(path, await readBytes(path), { images: true });The host supplies byte access and optional OCR; this package performs no file/network I/O and holds no credentials. Bind readBytes(path) to the authorized workspace/view for this conversation. Read failures propagate as native tool errors. Both readers use replay: 'safe'; recovery may reread bytes or call OCR again, so the host owns stable revisions, current authorization and OCR cost policy.
read_pdf(path, pages?)returns{ path, totalPages, pages: [{ n, text, ocr?, truncated? }], untrusted: true }. Page lists such as"1-3,5"are sorted and deduplicated, at most 30 pages per call; without a list, the first 30 pages and anexthint when more remain. Each page is capped at 20,000 characters. Requested pages without text go toocr(bytes, pageNumbers), which returns{ pages?: [{ n, text? }], provider? }. Only matching scanned pages are replaced and markedocr: true; absent or failed OCR adds a note. Unlike the Factory reference's media-type argument, this callback receives the selected scanned page numbers.read_spreadsheet(path, sheet?, range?)reads xlsx, xls, ods and CSV. It returnssheetNamesand{ name, rows, columns, shown, csv, truncated? }for each selected sheet, plusuntrusted: true. The limits are 20 sheets, 200 rows per sheet by default, 1,000 with a cell range such asA1:C1500, and 60,000 CSV characters per sheet. Formulas use their last computed values, never execute.sniffuses magic numbers and ZIP entry names, not extensions (except CSV versus other UTF-8 text).readStoredreturns JSON text containing{ path, content }for text/CSV; images up to 4 MiB add nativeImageContentwhenimagesis true. Other binaries, oversized images and image-incapable models return{ path, type, size, error }; PDF/spreadsheet refusals point to their reader.imagesaffects this helper, not the two extraction tools.
Results use native output for the model and the same metadata in details. As in the reference, typed refusals are ordinary results carrying error, not isError: true. Document content is untrusted evidence, never instructions or consent. The readers set Pi's native output byte limit to 8 MiB to retain valid JSON at the extraction caps; the JSON occupies one native output line. These are returned-extraction limits, not bounds on input bytes or parser memory/CPU. Enforce input and resource budgets in the host.
PDF reading uses the pinned regular dependency [email protected] (MIT). Spreadsheet reading needs the optional peer xlsx (SheetJS, Apache-2.0); install the official distribution in the host with npm i https://cdn.sheetjs.com/xlsx-0.20.3/xlsx-0.20.3.tgz, not npm's old [email protected]. Without it, the spreadsheet tool returns a clear refusal; PDF reading still works. Both packages are imported lazily inside their reader only. Their notices ship in THIRD_PARTY_NOTICES.md. test/packages/agent-documents.test.mjs drives public registrations through a real Harness with generated fictional files, and npm run test:question-consumer exercises the packed agent without SheetJS; no live OCR provider is qualified.
Approval gate
requireApproval(tool, { summarize }) from @hachej/boring-agent/approval wraps a native tool so each call waits for the person's Approve or Deny before execute. It reuses the ask-user mechanism (askPerson from @hachej/boring-agent/ask-user): a conversation document keyed by the tool call ID, answered with answerUserQuestion through the chat transport's answer operation, and shown by the pi-chat approval card. The check runs inside the tool's own execution in host code, so the model cannot skip it. While the call waits, the one-line summarize text is also published as the call's running details under APPROVAL_DETAILS (it stays as the result's details unless the tool returns its own). That is also how pi-chat recognizes a gated call: no list of tool names is needed. The card shows the summary as the headline with the raw arguments collapsed. Deny (or a stopped conversation) returns an error result starting DENIED_PREFIX and execute never runs. The wrapper is replay-safe: a pending approval survives a restart, and a retained native memo makes execution at most once (after a crash during execution the model gets an "outcome unknown" error instead of a repeat of the change). Reads are simply not wrapped.
createSandboxExtension({ tools, flags?, simulate? }) from @hachej/boring-agent/sandbox is a fail-closed sandbox as a native extension (boring.sandbox, or name). Its wraps replace each listed tool that the host flags mutates or writes, or does not declare sandboxSafe or mutates: false/writes: false (in flags), with a recorder: the call is never executed, the model gets simulate(name, args) (default defaultAck), and the result's details carry { simulated: true, tool, arguments } under SANDBOX_DETAILS, which simulatedCalls(results) reads back. Its beforeTool hook blocks any call whose name is not in tools, so a tool added mid-run cannot run unnamed. Select it per conversation by including it in the complete selected list and applying that list with configure({ extensions: selected }) (not add: defaults keep their positions, so an add list does not show the effective order), after any approval extension, so no question is asked inside a sandbox. Composition limit (host-owned ordering): the sandbox must be the last wrap of each recorded tool. A wrap selected after it (for example createConvertingRead on read) replaces the recorder's execute, and that call runs for real. Pi 1.2 has no public seam that tells the executing phase which composed tool it runs, so the sandbox cannot refuse this at call time. The host checks the order at setup with assertSandboxLast(selected, sandbox) over the COMPLETE selected list, the same array it applies with configure({ extensions: selected }) (@hachej/boring-agent/sandbox); it throws on a later wrap of a recorded tool. Nothing in Pi calls it, so a host that composes extensions without it has no protection against that ordering. The gap is recorded in docs/upstream/pi-hookapi-composed-tool.md. The name gate in beforeTool is public and holds at call time: a call to a tool the sandbox does not name is refused.
createApprovalExtension({ name?, tools: { bash: true, write_note: { summarize, when } } }) is the same gate as an opt-in native extension: it brings no tools, only native wraps that decorate whichever tool of each name wins in a conversation, including Pi's stock bash, write and edit. Pi applies wraps only where the extension is selected, so the gate is per conversation: conversation.configure({ extensions: { add: [approvals] } }) turns it on and remove turns it off from the next call. when(args, call) lets a rule ask only for some calls (for example any method but GET); summarize(args, call) writes the card's headline (default: the tool name and its arguments). call is { conversationId, callId, toolName }, so a host with one conversation per site or project can name it in the headline. Approval extensions compose: a tool already gated is not gated again, so the first selected rule for a tool decides. requireApproval(tool, { summarize, when? }) remains the building block for a tool you define yourself.
Code mode: gated tools are native tools and must not be exposed to a code-mode sandbox, whose nested calls cannot reach a person. Give run_code the read tools only and send changes through the gated native tool. isApprovalGated(tool) lets a host filter automatically.
OptChat memory
@hachej/boring-agent/memory/optchat is Victor Taelin's OptChat, aligned with uniichat at 0500bf6 as one native pi-durable extension: an endless conversation whose memory is the conversation itself, shown to the model as a compressed tree. Credit for the idea, the algorithm and the summarizer prompt is his; this package maps it onto Pi.
const memory = createOptChatMemory({ harness: () => harness, context, summarizer: { models, model: { provider, modelId } } });
registry.install(memory.extension); // like any extension
await conversation.configure({ extensions: { add: [memory.extension] } }, context); // on, for this conversation
await conversation.configure({ extensions: { remove: [memory.extension] } }, context); // off againPi applies an extension's hooks only in conversations that select it, so memory is opt-in per conversation and there is no separate mode. A host that selects extensions by default selects it everywhere; give conversations an explicit extensions list to keep it opt-in. createOptChatMemory(options) returns { extension, nap(id), stats(id), zoom(id, leafId, n), date(id, leafId), forgetMessage(id, messageIndex), forget(id), dispose() }. Options: harness, context (long-lived, for background work), summarizer (required: { model }, where model may be a function (conversationId) => ModelRef, sync or async, for example that conversation's own model, and models defaults to the Harness's; or { summarize(job, signal) } returning one line), nodeBytes (512), viewBytes (128000), compactBytes (32000, the cold-start/callback compactor view), foldHighWater (1.25) and foldLowWater (0.75), fractions of both view budgets, notes (seed strings, stored once per conversation before the transcript), workOf(message) (host classification of subagent or job reports), jobs (64, how many summary tasks of a conversation the host keeps live at once), tries (5), cap (30000), retryMs (10000 initially, doubling after each failure to at most 60000), settleTimeoutMs (60000), agentName, name, onError. dispose() only stops this process's waits: the summary tasks belong to Pi, which keeps and recovers them, so it is harmless to skip and safe to call before harness.close.
| Reference | Here |
| --- | --- |
| Append-only log of user, talk, tool and echo messages | The Pi transcript, never rewritten. Leaves are derived from the model context: a user message is user, assistant text talk, each tool call tool, each tool result echo; thinking is not logged. A leaf over cap characters keeps head and tail. Seeded note leaves precede them; messages marked by workOf become one work leaf |
| Summary tree: node (l, i) covers messages [i·2^l, (i+1)·2^l), at most nodeBytes | The same, purely binary. A short message, or two children that fit together, are their own text and cost no call |
| The view tiles the whole chat, one id+n\|text line per node, fit incrementally by merging the most due pair (age / 2^(level+2)), never splitting | The checkpoint fold (advanceFold): append between checkpoints; above 125% of the budget, merge deterministically down to 75%. Both fractions are configurable. The public four-argument core helper retains strict fitting. renderView produces the <chat> block |
| Each turn calls the model with [system][view][new message] | GenerationTask.beforeRequest replaces the messages before the current turn with the view, as four-line text blocks at the start of the current turn's first message. The turn in progress starts at the latest user or work message. Work reports and sibling results whose tool calls fell outside the turn become ordinary user-role input, with their full text preserved. Every positional system message is kept in order. The explanation of the view is a prompt section, so the system prompt and tools never change between calls |
| Compactor: context first, no ids, scale line, resubmit with the cut at the limit, JOBS in parallel, patient retry | The same protocol (askModel), run by native pi-durable tasks: each node (level, index, fingerprint) is one boring.memory.optchat.node task (shipped in the extension's tasks, input { key, level, index, of, upTo }, one summarize phase) that calls the summarizer through runtime.models (or your summarize) and, in one commit, writes the node and the summarizer's usage into the extension's state. Pi schedules, runs and recovers them. See below |
| A turn waits until every line of the view is a summary and never shows cut text | The request waits up to settleTimeoutMs; after that the missing lines show (not summarized yet: zoom it), never a clipped message, and zoom(id, 1) returns the message whole |
| zoom(id, n) and date(id) | Native replay-safe tools of the same names |
| Summary files | A conversation document family (boring.memory.optchat.nodes, shards of 64 nodes per level, fork: 'current'), one fingerprint per node over the content it covers. A fork or an edit never shows a summary of different content: a stale node is unbuilt until rebuilt |
What is native, what is host-side:
- Tasks. The host creates node tasks with
conversation.commit(tx => tx.createTask(...))(hooks are read-only) asbackground: true, conversation-owned tasks: they never keep the conversation busy, never block or get aborted with the user's run (conversation.abort()leaves them;abort(context, { background: true })aborts them), and show inharness.taskGraph()andharness.inspect(). A pending task after a restart is resumed by Pi once the extension is installed again (the extension carries the task definition; the options must be the same). - Triggers (host admission, not scheduling). After an answer (
onYield, once the run is idle) and when a request needs its view (beforeRequest). A trigger creates the tasks of the nodes whose sources exist and whose preceding view is summarized. One live task per node key, found by scanning native tasks in the same commit that creates them.jobscaps how many are live per conversation; Pi has no task concurrency setting, so this is the host's admission cap and Pi's scheduler decides when they run. A node's parent is created by the next trigger or by the waitingnapor request, once its children are built. - Waiting. A request waits with
waitForTaskon the live node tasks, up tosettleTimeoutMs, then shows the placeholder.napwaits until all nodes are built, including retries. These waits are in-process; if the process dies the tasks are not lost, only the cascade to parents waits for the next trigger. - Retry. A failed node is a failed native task (
outcome.failed). Its failure time and streak length live inboring.memory.optchat.statewith call count and spend. The host's idle admission loop waits fromretryMs, doubling to at most 60 seconds, then creates another native task without a new turn.dispose()aborts those waits, not Pi's tasks. After a process restart,napor another trigger restarts admission. Requests retain their bounded wait and can show placeholders during an outage.onErrorreports only the first failure of a streak. - Usage. In the commit that stores a node (or records its failure) the task adds the summarizer's usage, all attempts of the cut-and-resubmit loop included, to the extension's own state document (
boring.memory.optchat.state), reported bystats().compactor.usage. It is not written into Pi'spi.usage: Pi has no public way for a task to record model usage (recordUsageis internal), so per BORING-PI-4 that guarantee is unavailable here rather than worked around; a public usage-recording seam is an upstream request. Hosts that budget model spend addstats().compactor.usagetoharness.usage(). Task and document kinds are all inboring.memory.optchat.*; this package writes nopi.*document. - Still host-side. The trigger and admission logic, the log cache and the summarizer prompt. Context refreshes reuse a prior leaf only after exact kind, capped-text and timestamp comparison, avoiding repeated byte counting and hashing without trusting native message identity. The emitted turn and compactor tilings, open flags and content fingerprints are retained in
boring.memory.optchat.fold(no duplicated message or summary text), so cache eviction and restart restore the same prefix with unchanged options and history. A sent interior placeholder stays frozen until a checkpoint (the tail may settle); later summaries remain accessible through zoom. History changes, forgetting or changed fold options rebuild the view. A customsummarizefunction has no model usage to record. A model call interrupted by a crash is rerun from the start of the task, and its spend is not recorded.
Deviations, honestly:
- The log is Pi's model context, not an OptChat file, so ids are positions in that context. A native compaction, reset or edit that changes earlier messages invalidates the summaries it touches (fingerprints), and the view is refolded.
beforeCompactdeclines while the extension is selected: the view replaces compaction, and a native summary would cut the transcript the view is built from. That is the only effect on Pi's lifecycle. - The current turn is verbatim (including its tool steps) instead of being summarized to one line each as soon as it is logged; it enters the view when the next user message arrives. A user message sent mid-run starts a new "current turn" for the next step.
- A request is rewritten once per attempt from the full context. The summaries are a cache rebuilt from the log; the summarizer's calls are not Pi generations: they run as Pi tasks and their usage is in
stats().compactor.usage, notpi.usage(see above). - Both views use four-line text blocks. Model compactor requests carry the entire last emitted turn view (bare lines), even when their job revisits older history; the admitted
upTostill bounds the source they may summarize. Before the first turn, native documents retain one frozen, whole-block prefix withincompactBytes × foldHighWater. Callback summarizers keep their past-onlycompactBytescontext. The constant size example is in the summarizer system prompt rather than every job suffix. The first native task on a new conversation/model/prefix sends alone; other admitted tasks await its response, then send in parallel within the samejobscap. Failure or cancellation releases the warmup wait. This is process-local request coordination, not a scheduler or proof a provider retained its cache. stats().compactor.requestsexposes provider-reported input, cache reads/writes, output and cost for the most recent 256 responses booked by settled native tasks, including retries, oldest first. The history limit isCOMPACTOR_REQUEST_HISTORY_LIMIT, next toDEFAULTS; lifetimecallsand aggregateusagetotals are not truncated. These records survive restart; old state documents start with no request records. Interrupted tasks can lose uncommitted usage, as before. Pi 1.2 exposes no per-block cache marker: the fictional Anthropic replay supplies host breakpoints and measures cold starts plus 100 turns at 600/full-fold and 400/default messages. It enforces ≥95% cache reads, fewer uncached tokens and no extra summarizer calls; this is not live-provider qualification. Longer cached context increases total input. Qualify the host's chosen budgets, breakpoints and retention with its provider. There is no separatefsynclog or single-writer lock; Pi's storage owns durability. Summaries are read from documents on every request and stats call.- Cost: the compactor makes about two calls per message, each carrying its small view as context. Choose a cheap summarizer model; the browser-agent example uses the chat model only because it has one.
- The package runs in Node and in a browser (no Node-only imports). Fictional-data tests are in
test/packages/agent.test.mjs;examples/botandexamples/browser-agenthave journeys.
Forget a message
await memory.forgetMessage(conversationId, messageIndex) forgets one message in Pi's current model context. messageIndex is its zero-based position in (await conversation.context(context)).messages, including system-message positions. System messages cannot be forgotten. This is distinct from the leaf id used by zoom; seeded notes and assistant tool calls can shift leaf ids.
The operation replaces every leaf of that message with (forgotten) at the same positions. One native commit stores the tombstone and deletes those leaf summaries, their ancestors, later summaries and corresponding failure records. Once a model summarizer has used the shared prefix, forgetting conservatively deletes all summaries: earlier nodes and transitive parents can also copy later facts. That fact is retained across restart and summarizer configuration changes. With only callback summaries, later summaries can contain facts resolved from earlier context, so deleting only the logarithmic structural ancestors would leave copies behind. Rebuilding can therefore cost more than logarithmic work. Later leaves keep their ids and fingerprints. Native tasks rebuild the invalidated summaries. In-flight calls recheck the memory revision inside their commit and cannot restore old output. A chat request waiting on summaries also rechecks that revision before returning its rewritten messages. nap waits for rebuilding. Repeated forgetting is harmless. forget(id) only evicts the process cache and does not undo tombstones.
This changes active memory only, not requests already dispatched. Pi's session entries, native task history, underlying storage pages, provider logs, backups and copies quoted in other messages or existing forks are not erased. Indices address the current context, so a reset or history replacement requires the host to resolve positions again. Hosts must authorize the operation. Seed notes are stored once in boring.memory.optchat.notes; changing options.notes does not overwrite an existing conversation's notes.
Prompt wording and algorithm adaptations retain the upstream Apache-2.0 attribution in NOTICE and the license copy. The file headers identify the changes.
Per-request prompt context
@hachej/boring-agent/prompt-context (createPromptContext({ context })) is one ordinary native extension whose GenerationHooks.beforeRequest (registered with hook(GenerationTask, ...)) runs before every request attempt, retries and crash recovery included. The hook's text is used for that request only: it is recomputed each time and never appended to the transcript. The text is one user-role <request-context>...</request-context> message placed immediately before the round's question when the final message is the user's question, otherwise at the tail (after tool results or an assistant continuation); system messages and earlier messages are unchanged, so the stable prefix stays cacheable.
Choose the seam by how the text changes and who should see it:
- Stable per-agent text (instructions, a fixed section):
AgentSpec.instructionsor aPromptSection. Pi stores a system entry whenever a section's text changes, so use it only for text that changes rarely. - Volatile or private text (the open document, the time, a permission snapshot): this hook. Nothing is stored and the person does not see it in the history.
- Text the person should see and keep:
prepareInputon the submit route, which becomes the user message.
context({ conversationId, messages, signal }) returns the text (undefined or '' adds nothing). The host keeps request-scoped state in its own store keyed by conversationId and reads it there; Pi's hook context carries no caller values. Install several extensions to compose independent contexts (they apply in install order).
Failure is fail-open and Pi's: a throwing context is reported through HarnessOptions.onReport and the request goes out without the context (Pi's hooks.each, scheduler.js). An aborted turn rethrows. This is advisory text, not policy or authorization; do not use it to enforce a decision. The text is sent to the model provider and gateways or telemetry that log request bodies may see it; the model can quote it into a stored assistant message. Compaction summarizes the stored transcript only, so the context is not folded into summaries. After a crash the hook runs again at recovery, so the value is fresh per attempt, not replayed. Placement: before the round's question when that is the last message; after tool results or an assistant continuation, at the tail. Tests: test/packages/agent-prompt-context.test.mjs.
Screen context: a browser can send what the person is looking at with each submit. createRemoteChat({ requestContext }) adds the returned plain object to the submit body as context. In ChatTransportAccess, requestContext(value) receives it after validation and before conversation.submit; a value that is not a plain JSON object of at most 4 KB is 400 invalid-request, and a host without requestContext ignores the field. The host stores context by conversation id in requestContext and renders it in createPromptContext({ context }); the transport keeps nothing. With useRemoteChat the context is the requestContext value as it is (name, params: a host written before placement keeps working) plus placement (docked or floating), reported by PiChat / AmbientChat; PiChat takes a placement prop for a host that floats it, as the AgentLayer does. renderRequestContext(value) (from @hachej/boring-agent/prompt-context) turns it into the agent's role and the screen for createPromptContext; placement changes behaviour, never authority (see PAGE-TOOLS.md). Tests: test/packages/agent-chat-transport-context.test.mjs, test/packages/ui-remote-chat-context.test.mjs, test/packages/agent-prompt-context.test.mjs, test/contracts/chat-placement.test.mjs, test/contracts/chat-placement-registry.test.mjs.
Room
@hachej/boring-agent/room is a shared room of people and agents on one pi-durable session that never runs a model (the Studio channel plan, boring-factory#566). Conversations are named (main, admin, agents:<a>:<b>); createRoom({ harness, context }) maps each name to a native conversation on first use.
- Entries. Seven kinds (
room.message,room.removed,room.card,room.pin,room.question,room.action,room.system); everydatanames itsauthor.write(name, { requestId, kind, data }, { people, agents })appends the entry, its native passive-write record (type: 'write',status: 'done', keyed by the writer-namespacedrequestId) and the wakes it causes in oneharness.commit. The samerequestIdagain returns the first entry and wakes nobody;Conversation.submitwith that id sees the same record. - The view.
roomView(entries, cards, viewer)is a pure fold: a removed message reads « message supprimé » without attachments, a question isansweredonce a message replies to it, a card shows the state the host passes in. The cursor is the last entry id;entries(name, after)resumes after it. - Who answers.
wakeFor(entry, room)is pure: mentions wake at most two agents, a reply wakes the agent replied to, a reply to a question answers it ({ kind: 'answer' }, not a new turn), and an unaddressed message wakesagent:pmonly when its sender is the only person in the room. Agents never wake each other inmain; inagents:<a>:<b>(agentsConversation(a, b)) each message wakes the other agent, and the sixth turn returns{ kind: 'capped' }. A person's card action wakes the card's agent; platform changes wake nobody. - Wakes. An open wake (
boring.room.wakes) is closed by the agent'ssettle(agent, wakeId, reply), aroom.messagekeyedwake:<wakeId>; without an open wake for that agent nothing is written.resend()is the alarm's step: a wake open for two minutes is resent with doubling backoff, at most five times. After that the room closes it itself asplatform:roomwith one « Je n'ai pas pu répondre. Réessayez. » message under the same key, so a late reply adds nothing. - The agent's side.
createRoomTurn({ conversation, context, port })runs in the agent's own home, lifted from the WhatsApp gateway:wake({ wakeId, input })records the owed settle, submitsroom:<wakeId>withwhenBusy: 'followUp', relays frames (port.stream) and newask_userquestions (port.question) fromconversation.watch, and callsport.settlewith the answer text. A resent wake finds its submission byrequestIdand settles again without a new run.start()afterharness.resume()settles what a crash left owed;answer(callId, text)feeds a room reply into the pendingask_user.
Membership, roles, rates, spend and the doors stay with the host, which checks them before calling. Tests: test/packages/agent-room.test.mjs, and test/contracts/room-crash.test.mjs (SIGKILL mid-run, before and after the settle).
Chat over a hibernatable WebSocket
createConversationSocketHandler (from @hachej/boring-agent/chat-transport) serves the chat transport over WebSockets accepted with the Durable Object Hibernation API, so an idle open page does not keep the object in memory (or billed). One socket carries the watch (the same view then ops frames as the NDJSON stream) and every call: {"kind":"call","id","op","params","body"} is answered by {"kind":"reply","id","status","body"} and runs through createChatTransportHandler unchanged (validation, allow, project, submission-refused, request-id deduplication). A submit without whenBusy is a follow-up when the conversation is busy. ping is answered pong (on Cloudflare by setWebSocketAutoResponse, without waking the object).
Mount it in a Durable Object (any host with the same acceptWebSocket/getWebSockets and attachment methods works the same):
sockets = createConversationSocketHandler({ state: this.ctx,
// Upgrade only: who and which conversation, kept in the socket's attachment (`serializeAttachment`). Null answers 401.
authenticate: async request => ({ owner: personOf(request), conversationId: selectedIn(request) }),
// From the attachment alone, at accept and after every wake: the same access the HTTP handler gets.
access: async ({ owner, conversationId }) => ({ conversation: await open(owner, conversationId), context }) });
fetch(request) { if (request.headers.get('upgrade') === 'websocket') return this.sockets.fetch(request); /* ... */ }
webSocketMessage(socket, message) { return this.sockets.webSocketMessage(socket, message); }
webSocketClose(socket, code, reason) { return this.sockets.webSocketClose(socket, code, reason); }
alarm() { void this.sockets.resume(); /* ... */ } // or Lifecycle's host `onStart`After a wake the in-memory watches are gone; resume() (also run by every message and upgrade) reopens each socket's watch from its attachment and sends a complete view first. broadcast(event, { owner?, conversationId? }) sends host events ({"kind":"event","event"}: presence, the conversation list) on the same sockets, and connections({ owner?, conversationId? }) lists the open sockets' attachments. A browser cannot set headers on a WebSocket: a client offers boring.chat as subprotocol (the handler selects it) and a bearer host reads its credential from another offered value, as the Cloudflare recipe does. Pi keeps idle documents and contexts in memory on ten-minute timers by default, and a pending timer keeps a Durable Object from hibernating: open the harness with settings: { documentRetentionMs: 0, contextRetentionMs: 0 }.
The client is createRemoteChat({ transport: 'websocket' }) (or useRemoteChat({ transport: 'websocket' })): the controller API is unchanged; unanswered calls are sent again unchanged (same request ID) on the next socket, each socket starts from a complete view, and if the first socket cannot open it uses HTTP. Reused from Cloudflare: the platform Hibernation API itself; the agents SDK WebSockets capability (agents/websockets) was not used because it is a Lifecycle catch-all that claims every upgrade and speaks the Agent protocol, while this handler must mount in a plain Durable Object too. PiHarness leaves "how a session reaches a client" to the host. Tests: test/packages/agent-chat-socket.test.mjs; the recipe journey examples/hosts/cloudflare/journey.mjs (CF_HIBERNATION=1 proves hibernation with the socket open).
Native questions
createQuestions from @hachej/boring-agent/questions exposes a native task, session document family and extension. Install the extension explicitly. Call admit(tx, question, ownership, context) in a native commit to persist a single-choice question and its task together. resolve and consume take the caller's native transaction so host writes can share that atomic commit. Call them before host table writes: native task reads after a table write are unavailable and the operation returns conflict. Host authentication and current subject/policy checks run at both boundaries. A resolved answer is clarification, never publication permission.
The question task runs its durable wait phase while observing the native document. Parents can park with native task waiting on its ID. No browser or process-local waiter registry owns the answer. Native shutdown cancels observation; reopening checks the retained document. Session documents retain resolved/cancelled evidence after task completion. Expiry changes only pending state; late consumption refuses without deleting a prior answer. If supplying a custom clock, use the same clock for the Harness. Raw native document access is trusted host access; remote question resolution is still pending.
Validated document delivery
createDocumentDelivery from @hachej/boring-agent/delivery admits a producer callback and its delivery task in one native transaction. The callback creates ordinary native tasks and returns TaskId<string>. Delivery captures the document precondition and authenticated identity before production, waits on the native producer, validates its actual string output and persists that exact text before publication. Host validator versions and operation namespaces must remain stable across recovery.
This is string validation and conditional Markdown delivery. It does not implement model repairs, arbitrary host record delivery or prove tool provenance. Supply both ResourcePublisher and PublicationLookup. Recovery defaults to lookup only after durable publication intent; a missing receipt stays unknown. Explicit replay: 'safe' requires host qualification of durable duplicate suppression. Abort means the native task stopped; an interrupted external publication can still require lookup under the admitted operation ID.
The admission target can include preconditions: readonly ResourceExpectation[]. Use these for source-owned generation and human-edit revisions that must still match when output is published. Admission copies and validates the target and conditions before awaiting producer creation. The provider checks the guards in the same transaction as the document change; delivery includes them in its receipt digest. Independent subjects can use independent guards.
await conversation.commit(tx => delivery.admit(tx, createProducer,
{ kind: 'absent', target: report, preconditions: [generation, humanEdit] },
{ ownership: { kind: 'conversation' } }, context), context);The native delivery task is version 2. Existing version 1 records migrate with empty guards and unchanged producer, identity, target, namespace, validation version and checkpoint. Downgrading to a version 1 definition refuses guarded version 2 work. A committed receipt remains the result of that original delivery after a guard advances; a guard change before publication causes conflict. Advancing a provider guard and admitting native tasks are still two separate transactions. Hosts need recovery for that boundary; this API does not make them atomic.
resolveAccess may return a Promise. When recovery reads the original actor from native producer input, pass that already-authenticated access as the sixth admission argument: admit(tx, createProducer, target, taskOptions, context, access). Admission captures its identity before awaiting producer creation. Publication still resolves current access and compares the original identity. Omitting the sixth argument preserves resolution during admission; such a resolver must not read the same Harness or call tx.task after producer writes. Native transactions reject table reads after their first write. Explicit access is trusted host input, not authentication or a grant. Resolved credential fields are copied before digest, memo and provider awaits.
Run node fixtures/background-document.mjs after building for fictional question, native producer and document delivery without a model, browser or shell. The example resolves a fixed fictional answer in trusted host code; it is not an authenticated remote human journey.
Import interfaces with import type. See scaffold guide, native examples and implementation evidence.
Authorized conversation text
createConversationProjectionHandler from @hachej/boring-agent/projection returns a Fetch Request to Response handler. The host authenticates the request and supplies the original native conversation, Context, fixed runtime/scope/principal binding, current authorization callbacks and a revocation signal. Requests use GET with ?version=2. Other versions return 426 and other methods return 405. No command or asset route is installed.
The response is newline-delimited JSON with boring.conversation-text version 2 frames. The first frame is a bounded snapshot; subsequent frames contain changed rows and the complete bounded row order, or a replacement snapshot when that is smaller. A delta names its preceding frame. Connection ID, frame and observation time describe this observation only; they are not native durable cursors. The stable row key is the original conversation ID, entry ID and message index. Rows absent from a delta's order are removed.
Only host-permitted user/assistant text blocks are selected. Entry data, system instructions, thinking, tool arguments/results, assets, Context and errors are excluded. Live generation is also excluded; en
