WASM Engine ABI
The browser build of kapi (kapi/cmd/kapi-wasm-cli) registers a set of global
JS functions at boot. That set is a versioned contract, the ABI consumed by
the @neokapi/engine npm package (packages/engine), which wraps it in the
typed KapiRuntime facade.
Contract
- Single registration point. Every entry point is declared in the
engineExportstable inkapi/cmd/kapi-wasm-cli/main.goand installed onglobalThisfrom there. Nothing else registers engine globals. - Feature detection.
kapiEngineABI()returns{abi, version, functions}: the ABI major version, the kapi build version, and the list of registered global names. A build withoutkapiEngineABIpredates the descriptor and is treated as abi 0 (probe individual globals). - Additive within a version. For one
abivalue, changes are strictly additive: new functions may appear infunctions, but existing names, signatures, and payload shapes never change or disappear. Renaming or removing an entry point, or changing a signature incompatibly, bumps the version. - Boot handshake. The host installs its
fs/processshims (thewasm_exec.jsenvironment) and the SQLite bridge (__kapiSQL, see Storage) before instantiation; after registering the globals, the engine invokes the host's__kapiCliReady()callback and then blocks forever so the globals stay callable. In a browser the host is the engine's Worker (The Worker), so the globals live there and the facade reaches them by message. - Reverse bridges. Some features call back into optional host-provided
globals:
__kapiPdfium(PDF text + geometry),kapiLocalGenerate(on-device LLM),kapiLocalNER(on-device NER), andkapiBrowserTranslate(with the platformTranslatorAPI). Each degrades with an actionable error when its bridge is absent. The typed interfaces live inpackages/engine/src/capabilities.ts. A page installs them on its ownglobalThis, and the engine finds them there from the page or from a Worker.
The Worker
bootKapiRuntime starts the engine in a dedicated Worker
(packages/engine/src/worker.ts) wherever Worker exists: Go, SQLite and the
engine's file system run there, off the page's thread. { worker: false }, or
an environment without Worker (Node, a test), runs it on the calling thread
as before, with the workspace in memory.
- Calls by message. The facade sends each call as a message naming the
engine global and its arguments (
packages/engine/src/protocol.ts), and the Worker answers with the value. Calls that are not engine globals (removeDatabase,reset,syncContext, and reading back a trace) run beside them in the Worker (packages/engine/src/local.ts).kbf,segment,segmentEnginesandremoveDatabaseanswer Promises on the facade, in both modes. - The file system has a mirror on the page.
runtime.volstays synchronous: the page holds a copy of the engine's file system, a write on the page reaches the Worker before the next call (messages keep their order), and the Worker sends the changes a call made before the call's answer, so a page that readsvolafter a call sees them.memfsreports each changed path (onChange) for this. - The ABI descriptor. The Worker sends
kapiEngineABI()with its ready message, and the facade installs it on the page, soengineABI()andhasEngineFunction()answer there. - Reverse bridges stay on the page. With each call the facade sends which
bridges the page has, and the Worker installs a stand-in for each that asks
the page and waits for its answer. A stand-in answers with a Promise, so the
engine waits for it on a goroutine:
labSegmentAsyncislabSegmentanswered as a Promise for that reason, and the ICU4X engine (core/segment/icu4xjs) accepts a Promise from its bridge. The Worker answerskapiIntlSentenceBreaksitself, sinceIntl.Segmenterexists there, and mirrors the presence of the platformTranslatorAPI. - Loading
wasm_exec.js. The Worker imports it as a module, falling back toimportScriptsin a classic Worker. A host on another origin serves it with CORS, as it already does for the engine binary.
Storage
Every store in the browser build is the native code: the workspace and its
operation log, the projector, the content memory, the terms store, the voice
store, the decision ledger, the context graph and the block cache. They are SQL
behind core/storage, and the browser build's driver runs that SQL on the
official SQLite WebAssembly build, @sqlite.org/sqlite-wasm.
-
Where the workspace is kept. In the Worker, the engine keeps its databases in SQLite's
opfs-sahpoolVFS, in the origin private file system, and the files of its file system in a database of its own in the same pool (/.kapi-engine/volume.db, outside the driver's namespace), written after each call. Both survive a reload and a browser restart, and need no COOP or COEP headers, which GitHub Pages cannot set.runtime.storagesays which applies:{ kind: "opfs" }, or{ kind: "memory", reason }withreasonone ofdisabled,main-thread,unsupported,another-tab,takenorfailed, anddescribeStorage()turns it into a sentence for a page.runtime.onStorageChange()reports each change. The pool is named by a key (BootOptions.persist, default"kapi"); the docs key it by their base URL, since the stable docs, the preview channel and each pull request preview share an origin and serve different engines. -
One tab owns the pool. The pool holds a synchronous handle on every file, which only one context may hold, so the Worker first takes a Web Lock named for the pool and holds it for its life. Every tab waits a moment for the lock (a reload releases it as the old page closes).
BootOptions.whenHeldsays what a tab does when another tab still holds it:"memory"(the default) runs in memory withreason: "another-tab","wait"waits until the owner lets go, and"take"takes the workspace over. Before installing the pool the Worker checks that no other context still holds its files and waits for one that is closing: a failed install in this release ofsqlite-wasmremoves the pool's directory. The pool grows between calls, since growing it waits on the file system, keeping spare slots for the databases and journals a call creates. -
Handing the workspace over.
runtime.takeOver()moves the workspace to the tab that calls it. The calls in flight finish first; then the facade starts a second Worker that requests the lock withsteal: true. Stealing rejects the owner's held lock request, which is how the owner learns it lost the workspace: its Worker keeps the file changes not yet kept, stops using the pool and sendslost, and its page fails the calls in flight, stops that Worker (which releases the pool's file handles) and starts another in memory with the page's files, sostoragebecomes{ kind: "memory", reason: "taken" }. The new owner waits for the handles to be free (up to 15 seconds), installs the pool and replaces the tab's files with the kept ones; the facade moves onto that Worker and stops the old one. When the pool cannot be opened,takeOver()rejects and the tab stays as it was. A Worker that loses the lock before it is ready is replaced by one in memory. The playground's files panel offers Use the workspace here while another tab holds the workspace. -
Locks between connections. The pool's own locks only remember the level asked for. The engine opens several connections to one database, so the bridge installs a lock table over the pool's methods (
packages/engine/src/locks.ts) that keeps SQLite's lock levels per database, asmemdbandos_unix.cdo within one process: a write beside another connection's transaction reportsdatabase is lockedat once, as it does in memory. -
The workspace is a cache. The browser may evict what a site keeps (Safari removes data a script wrote after seven days without interaction), so the workspace in a browser is a cache of a log that should also live elsewhere.
kapiExportWorkspace()packs the engine's files and the context of every project among them into a workspace package (kpz.KindWorkspace,kapi-workspace): the files underfiles/, and per project a context package undercontexts/, the operation logkapi store exportwrites.kapiImportWorkspace(bytes)writes the files and merges each log, askapi store importdoes, which rebuilds the project's stores. A terms store outside every project (onekapi terms import --filewrote, say) has no log, so it travels as a terms bundle undertermstores/, which the manifest maps to the store's path, and an import adds its concepts and relations to a store at that path. The stores in a project's.kapi/are projections of its log and stay out; so do the data root,/tmpand the lab's own directory (/.lab). The facade'sexportWorkspace()andimportWorkspace()wrap them, and the playground's files panel offers both. -
A reset, then an import. A reset forgets each project in the workspace (
workspace.Forget), which recordsproject.forgetin the log and keeps the operations before it for history; a store starts after the latest removal. The workspace treats an operation held only before its project's removal as not held:Recordwrites it again after the removal, a pull counts it as new, a push leaves out every operation before the removal, and the removal clears what the workspace knew about the project's remotes. So importing the package exported before a reset, in the same browser, merges the project's log again and rebuilds its stores. -
Sync through a folder.
kapiSyncContext(remote, options)pulls a project's context from a remote the page holds and pushes what the engine recorded (App.SyncProjectContextWith, the same pull and pushkapi context syncruns), and resolves to{pull, push}. The remote is an object withlist,getandput(kapi/cmd/kapi-wasm-cli/contextremote.go);folderRemote(handle)(packages/engine/src/folderremote.ts) builds one over aFileSystemDirectoryHandle, fromshowDirectoryPicker()or the origin private file system. The folder keeps the layoutFileRemotekeeps on disk, one file per object, each written under a dot-name and moved into place, so a recipe withcontext: {backend: file, path: <folder>}on the same computer shares the context. The facade'ssyncContext(folder, opts)sends the handle to the Worker, where the remote is built, and the files panel offers Sync context with a folder whereshowDirectoryPickerexists (Chromium). The other backends stay native: agitbackend runs thegitexecutable, which a page cannot start, and thes3backend reads the AWS credential chain, which a page has no safe place to hold. -
The bridge.
packages/engine/src/sqlite.tsloads the module and installs one object onglobalThis,__kapiSQL, before Go starts. The Go driver (core/storage/driver_js.go,core/storage/sqlitejs_js.go) registers withdatabase/sqland calls the bridge synchronously throughsyscall/js, so Go and SQLite share the page's main thread. Arguments and result rows cross in one packed byte buffer per call. A failure comes back as SQLite's own message and extended result code. -
Where the databases live. Named by absolute path, in the pool or, in memory, in SQLite's
memdbVFS. Every connection to one name shares the database, andATTACHby path reaches it, since the bridge makes its VFS the default. In memory the bridge holds a connection of its own on each name, so a database outlives the pools that open and close it between commands. The engine names its data root/.kapi-data(KAPI_DATA_DIR), where the workspace lives. -
The driver's profile.
storage.DriverProfile()reports one connection per file, no WAL and no cross-process lock, and durability when the bridge says its databases outlive the page (__kapiSQL.durable). The busy timeout is zero: a second connection's wait would spin the only thread, so a lock conflict reportsdatabase is lockedat once. Code on these pools never holds a transaction or open rows and then waits for a second session on the same pool from the same goroutine. The block store's iterators read a page at a time and release the connection before yielding, so a caller may write while it iterates. A failedCOMMITis rolled back, as the native driver does, so the pool's one connection never stays inside a transaction.make test-stores-oneconnruns the store suites natively with every pool held to one connection and WAL off, to find such code without a browser: a call that waits 20 seconds for the pool panics with every goroutine's stack, and a read on one pool beside a write the same code holds on another waits out the busy timeout and fails withdatabase is locked, which the browser reports at once. CI runs it besidemake test-wasm-stores, and on push runs the host suite the same way (make test-host-oneconn). -
Database files belong to the driver. The page's file system never sees a database, so code that asks whether one exists, removes, renames or lists them calls
storage.Exists,storage.Remove,storage.Rename,storage.Listandstorage.RemoveAllrather thanos. A database's directory still has to exist in the engine's file system, as it does natively. -
A database file a page adds. A SQLite file in the engine's file system (one a reader uploads, say) is read into memory the first time a store opens its path, through
sqlite3_deserialize, and writes go to the copy in memory. The namespace answers for such files too, andstorage.Removedeletes the file with the database, so a removed database stays removed. A file that is not a database fails to open withfile is not a database, as it does natively; one with an unflushed write-ahead log beside it fails with a message that says so. -
Row batches. A query's rows cross 256 at a time, or about 1 MiB at a time when they are large.
-
Word search. The module carries SQLite's built-in FTS5 tokenizers and no ICU, so
storage.FTSWordTokenizerisunicode61. -
The asset.
sqlite3.wasmis served beside the engine binary, wheremake web-wasm-clistages it (withsqlite3.wasm.gz) from the pinned package, and the boot prefers the precompressed copy.bootKapiRuntime(wasmExecUrl, wasmUrl, { sqliteWasmUrl })takes another location. Measured with gzip -9, the asset is about 0.40 MB and the bundled JavaScript (the module's glue and the bridge, minified) about 0.07 MB. -
The lab project. The read-only annotators behind
labInspectAnnotatedlook terms up in a project the engine creates at/.lab/projecton first use, and opens again if a reset closed it. Its terms are the bundle inkapi/cmd/kapi-wasm-cli/fixtures/terms.json, imported through the projector, so they are operations in the workspace log as they would be natively. Commands typed in a lab resolve stores as natively: inside a project they use the project's, and outside one the standalone store a flag or the working directory names. -
Starting a directory over.
kapiReset(dir)forgets every project checked out at or belowdirin the workspace (App.ForgetProjectsUnder, which closes the stores it holds there, reads the registry for the projects a previous page registered there, and callsworkspace.Forgetfor each), then removes every database there outside the workspace's own. A project seeded there again begins with an empty context and an empty record, because the projector replays, and the context ledger reports, only what the log holds after a project's latest removal. A project is keyed by its recipe'sid:orname:, so the same sample seeded in two directories is one project with one context, and a reset under either directory forgets it whole: the learning labs reset their whole/learntree on Play for that reason.KapiRuntime.reset(dir)calls it and then clears the directory's files; the playground's Reset button and the terminal'srm(throughKapiRuntime.removeDatabase) use them. -
Tested in Node.
make test-wasm-storesruns the store suites underGOOS=jsin Node over the same bridge (scripts/wasm-stores/), and the change service's suites beside them (The change contract). A test that needs a subprocess (git, or a second writer process), directory permissions that reach a database, or preemption skips there and names which.
The change contract
A page that edits content sends the change contract
(E-09) to three
entry points, rather than a command line to kapiRun. Each takes the
contract's JSON as a string and an optional second string, the call's options,
and returns a Promise of a JSON string.
| Entry point | Takes | Resolves to |
|---|---|---|
kapiRead(requestJSON, optionsJSON?) | a read request: {doc, blocks, editions, cursor, limit} | a read page: each block's ref and rev, its text in placeholder form, its codes, plurals and selects, its other editions and the operations it accepts, and next for the following page |
kapiApply(changeSetJSON, optionsJSON?) | a kapi.change/v1 change set: an object with ops, JSONL, or an array of operations | the kapi.change-result/v1 result |
kapiDescribe(requestJSON, optionsJSON?) | {format} or {doc} | what the format supports of each operation |
- Options.
{project, actor}.projectnames the project the call acts in: itskapi.yaml, its root, or a path inside it. Without one, the call acts in the project discovery finds from the engine's working directory, as a command given no-pdoes, and outside any project a document is a path under that directory.actor, which only an apply takes, is{kind, name, session}withkindeitherpersonoragent. Without one, the sender is the onekapi applyrecords in the same environment: a person, unlessKAPI_ACTORor an agent host's marker says otherwise. An agent's term, memory and recipe operations, its decisions other than a pre-review (advise), anif_matchof*and thereportgate are refused asnot_permitted, as they are over MCP. - One service. The calls build the change service through
host.ReadChangesJSON,host.ApplyChangesJSONandhost.DescribeChangesJSON, from the same function the MCP edit tools use (callChangeService), with the commit check, the policy and the recorderkapi applyruns. A reference and a revision thatkapiReadreturns are the oneskapi inspectprints for the same file, andkapiApplytakes either. A bilingual file whose reader has to be told the language of the translation it holds, such as a PO catalog'smsgstr, is read in the one language other than the source that a read'seditionsor an apply's operations name, askapi applyreads one. - Answers. A request the service refuses, a change set that does not
decode included, resolves to a
kapi.change-result/v1whoseerrorsays why. The Promise rejects only for a failure the contract has no code for, such as aprojectthat names no file. On the facade,readanddescribethrowChangeRefused, which carries that result, andapplyresolves to its result whatever the status. - The record. In a project, an applied change set is recorded in the
workspace's log under
/.kapi-dataas onecontent.editoperation per document, the recordkapi applywrites natively, and the projector writes it into the block history. A later read shows a translation's basis from there. Outside a project nothing is recorded. Every edit is kept with the rest of the workspace (Storage). - One at a time. The calls, the commands
kapiRunruns (a completion included) andkapiResetshare the engine's App, so the engine runs them in turn: a command started while a call runs waits for it, and the other way round. A command replaces the App's configuration and a reset removes databases, so neither may run while a call waits on the file system. A call takes its project from its options and its source language from that project, not from the flags a command was given. Two writers of one file in the page still take turns at the commit: the file home's lock (core/storage/filelock) is a mutex of the process where the platform has no lock between processes. - Tested.
make test-wasm-storesruns, underGOOS=jsover the browser's SQLite driver,core/changeand its file home with the conformance suite (core/change/changetest), the lock, the host's service for a project through the same suite and its JSON entry points, and the engine's own test of the order its entry points take turns in.make change-wasm-smoke(scripts/verify-snippets/change-smoke.ts) boots the real engine in Node and drives the three calls through the facade: a read that matcheskapi inspect, an edit that lands byte for byte and is recorded, a stale replay, a translation's basis read back, and the refusals.
Command surface
kapiRun(argv) executes the ordinary kapi CLI, so the browser build has a
second contract alongside the global function set: which verbs it answers.
kapi apply and kapi inspect run there as they do natively, over the same
service as the change contract's entry points.
- One declaration.
cli.BrowserCommandSetis the browser build's command set, mirroringcli.KapiCommandSet(the native binary's) verb for verb.kapi/cmd/kapi-wasm-cliregisters it wholesale and declares nothing itself. - No missing verbs. A verb the browser cannot run (one needing a
subprocess, the OS keychain, the network, or a socket) is recorded in
cli.browserGapswith the facility it needs, and registers a command that reports it.unknown commandtherefore means the verb does not exist in kapi at all, never that the browser omitted it.--helpstill works on those verbs; their help text carries the limitation. - No MCP server.
kapi mcpis a recorded gap, and the build leaves the server out entirely: the host and cli files that import the MCP SDK are built with//go:build !js, so neither the SDK nor a tool it would serve reaches the engine.host/mcp_js.goandcli/mcp_js.gostand in for the few names the rest of the build uses.make test-wasm-storescompiles the host's tests for the browser, so a host test that drives an MCP tool sits in a file built with!js: themcp_*_test.gofiles, and the*_mcp_test.gofiles beside the tests they belong with. - No plugin host.
kapi pluginis a recorded gap: a plugin is a separate executable that kapi starts as a subprocess and reaches over gRPC, and a page can do neither. Thehost/pluginhostfiles that start a plugin or talk to one (the daemon pool, the gRPC clients for formats, segmenters, comments and source connectors, the subprocess launches) are built with//go:build !js, so gRPC and the plugin protobuf stay out of the engine. Discovery and the manifest-driven host build for the browser as well, andhost/pluginhost/runtime_js.gostands in for the launches the rest of the build names, each answering that the browser runs no plugin. A test that drives the plugin wire sits in a file built with!js. - Drift is a test failure.
cli.TestBrowserCommandSurfacecompares the two sets and fails when a verb appears in one and not the other, or when a gap's help metadata drifts from the command it stands in for. Adding a verb toKapiCommandSetis a decision: wire it up for the browser, or record why it cannot run there. - Runtime guard.
make wasm-surface-smoke(scripts/verify-snippets/command-surface-smoke.ts) boots the real wasm in Node, sweeps every reachable verb forunknown command, asserts each gap's message, and replays the argv the lab explorers themselves use. It runs in the docs snippet-verification workflow (docs-verify-snippets.yml). - Capture strips ANSI. The engine boots with
CLICOLOR_FORCE=1so the playground terminal renders kapi's real styling;runCapturehands output to program code instead, and strips the escapes so a--jsonpayload parses.
The same derivation feeds the docs: scripts/gen-refs reads
cli.BrowserUnavailableReason for each command's runnable-in-browser badge, so
the Command Reference cannot claim a verb runs in the lab when it does not.
Where the pieces live
| Concern | Location |
|---|---|
Registration table + kapiEngineABI() | kapi/cmd/kapi-wasm-cli/main.go |
| Browser command set + recorded gaps | cli/browsercmds.go |
| Surface drift guard | cli/browsercmds_test.go |
| Runtime surface smoke | scripts/verify-snippets/command-surface-smoke.ts |
| Ambient TS typings for the globals | packages/engine/src/globals.ts |
Wire shapes + engineABI() helper | packages/engine/src/abi.ts |
KapiRuntime facade + boot | packages/engine/src/runtime.ts |
SQLite bridge (__kapiSQL) | packages/engine/src/sqlite.ts |
| The engine's Worker and its messages | packages/engine/src/worker.ts, packages/engine/src/protocol.ts |
| Where the workspace is kept (pool, Web Lock, file store) | packages/engine/src/storage.ts |
Handing the workspace between tabs (takeOver, lost) | packages/engine/src/runtime.ts, packages/engine/src/worker.ts |
| Locks between connections on the pool | packages/engine/src/locks.ts |
Workspace export and import (kapiExportWorkspace, kapiImportWorkspace) | kapi/cmd/kapi-wasm-cli/workspace.go, kpz/workspacepkg.go |
| An operation held before its project's removal | core/workspace/local.go (Record), core/workspace/sync.go |
Sync through a folder (kapiSyncContext) | kapi/cmd/kapi-wasm-cli/contextremote.go, packages/engine/src/folderremote.ts, host/contextsync.go |
| Persistence smoke (reload, restart, a takeover, a waiting tab, a reset and re-import, a folder sync, an export) | scripts/wasm-persist/, make wasm-persist-smoke |
| Browser database driver + profile + namespace | core/storage/driver_js.go, core/storage/sqlitejs_js.go |
Store suites under GOOS=js | scripts/wasm-stores/, make test-wasm-stores |
| Lab project for the annotators | kapi/cmd/kapi-wasm-cli/labproject.go |
Starting a directory over (kapiReset) | kapi/cmd/kapi-wasm-cli/reset.go, App.ForgetProjectsUnder in host/projectstore.go |
The change contract (kapiRead, kapiApply, kapiDescribe) | kapi/cmd/kapi-wasm-cli/changes.go, host/changes_call.go |
| The change contract smoke | scripts/verify-snippets/change-smoke.ts, make change-wasm-smoke |
| Reverse-bridge capability types | packages/engine/src/capabilities.ts |
| Payload types (ContentTree, runs, the change contract) | @neokapi/contract-types (generated) |
When adding an entry point: add it to engineExports, type it in
globals.ts, surface it on the facade if user-facing, and leave the abi
value alone (additions don't bump it).