@conjureos/bridge
v0.3.0
Published
Single-source ConjureOS app bridge: the canonical wire-protocol types and the injected ES5 shim (window.__vfs / window.__conjureos), parameterized by transport (desktop iframe postMessage or react-native-webview). Desktop and mobile import this instead of
Downloads
511
Maintainers
Readme
@conjureos/bridge
Single source for the ConjureOS app bridge: the canonical wire-protocol
types and the injected ES5 shim (window.__vfs.*, window.__conjureos.*),
parameterized by transport. Desktop and mobile import this package instead of
vendoring copies of each other's bridge code.
Kernel-side dispatchers (the trusted routers that validate messages and enforce permissions) stay platform-specific and are out of scope here — this package is only what goes over the wire and what gets injected into the app document.
What's in the box
protocol.ts— every message an app puts on the wire (vfs.request,ai.request,report.request,auth.request,notify.request,actions.*,native.request) and every result shape the kernel returns, plus runtime type guards (isVFSRequest,isAIRequest, ...). Thenative.request/native.responsepair is part of the canonical protocol: mobile serves it, desktop stubs it with an "unsupported" response.schemaCompat.ts/fieldMap.ts— pure, dependency-free helpers for Phase-45 self-describing apps: the structural needs↔provides checker (schemaSatisfies) and the rename-only field-map applier/validator (applyFieldMap,validateFieldMap). See below.shim.ts—buildShim(opts)emits the<script>block injected into every app's HTML<head>. The output is deliberately ES5 (no arrow functions, template literals, orlet/const): it runs before any transpiled app code, in whatever the browser or device WebView ships.fixtures/bridge-parity.html— the cross-platform behavioral fixture. Install it as an app on desktop or open it via the mobile runner; every bridge op must pass or cleanly skip. A hang or unexpected shape is a bug.
The two transports
The app-facing API is identical on both platforms — same globals, method names, timeouts, and result shapes. Exactly one thing differs: how bytes move between the app document and the kernel.
| | transport: "iframe" (desktop) | transport: "react-native-webview" (mobile) |
|---|---|---|
| app → kernel | parent.postMessage(msg, '*') | window.ReactNativeWebView.postMessage(JSON.stringify(msg)) |
| kernel → app | window message events | injected window.__conjureosDeliver(msg), called via webViewRef.injectJavaScript |
import { buildShim } from "@conjureos/bridge";
// Desktop (ConjureOS src/kernel/sandbox.ts): splice into the app HTML head.
const shim = buildShim({
transport: "iframe",
viewportMode: "desktop",
signedIn: true,
isAdmin: false,
env: { usdaProxyUrl, recipesApiUrl },
});
const injected = html.replace("</head>", `${shim}</head>`);
// Mobile (conjureos-mobile src/bridge/shim.ts): prepend to the served document.
const shim = buildShim({
transport: "react-native-webview",
viewportMode: "mobile",
signedIn: true,
isAdmin: false,
env: {},
});Divergence knobs
Where the two platforms historically injected genuinely different behavior beyond transport, the difference sits behind an explicit option whose default preserves what each transport's platform ships today:
nativeBridge?: boolean— emitwindow.__conjureos.native(camera / photos / share, 120s timeout). Defaulttrueonreact-native-webview,falseoniframe(desktop's kernel stub fornative.requestis still pending; don't emit an API that would hang).actionsTimeoutStyle?: "local" | "kernel"— actions-bridge timeout semantics."local"(iframe default) is the desktop behavior: the shim's own timer is authoritative andinvoke()rejects attimeoutMswithcode: "TIMEOUT"('invoke timed out after <N>ms'; register/list reject'register timed out'/'list timed out')."kernel"(webview default) is the mobile behavior: the kernel's timer is authoritative and the shim keeps a backstop attimeoutMs + 1000(generic'actions.invoke timeout'messages, no code).
Everything else — VFS (5s), AI (60s idle, opt-in ai.chunk streaming),
report (45s), auth (10s), notify (10s, resolves the deny shape rather than
rejecting), error/console forwarding — is byte-for-byte shared.
AI: tool use + sampling (0.2)
ai.complete accepts optional sampling parameters and Anthropic-style tool
use. All new fields are optional; the 0.1 payload shape is unchanged.
New request fields (AIRequestPayload):
temperature?: number— sampling temperature, 0–1.topP?: number— nucleus sampling.stopSequences?: string[]— max 4 custom stop sequences.tools?: AIToolDefinition[]— JSON-Schema-typed tools the app offers the model:{ name, description?, inputSchema }(namematches[a-zA-Z0-9_-]{1,64};inputSchemais JSON Schema for the input object).toolChoice?: AIToolChoice—"auto" | "any" | "none" | { name }.
New result fields (AIDispatchResult):
stopReason?: "end_turn" | "max_tokens" | "stop_sequence" | "tool_use"toolUses?: AIToolUse[]—{ id, name, input }calls the model requested.
Tool execution is app-side. The platform never runs a tool for you. The
loop is: the model returns toolUses (with stopReason: "tool_use") → the
app runs its own code for each call → the app calls ai.complete again,
appending an assistant message that echoes the toolUses and a user message
carrying the answers as toolResults ({ toolUseId, content, isError? }):
const first = await __conjureos.ai.complete({
system: "…", messages,
tools: [{ name: "get_weather", inputSchema: { type: "object", properties: { city: { type: "string" } } } }],
});
if (first.stopReason === "tool_use") {
const results = first.toolUses.map((u) => ({ toolUseId: u.id, content: runMyTool(u) }));
const second = await __conjureos.ai.complete({
system: "…", tools,
messages: [
...messages,
{ role: "assistant", content: first.content || "", toolUses: first.toolUses },
{ role: "user", content: "", toolResults: results },
],
});
}AIChatMessage carries these turns without a content-blocks rewrite:
content stays a plain string; toolUses? is meaningful on
role: "assistant" and toolResults? on role: "user".
Note: platforms may reject stream: true (an onChunk callback) combined
with tools in v1 — do the tool-use turns non-streaming.
Self-describing apps: needs & provides (0.3)
Apps self-describe in their manifest. Provides = the app's existing
actions, each now carrying a typed returns?: ActionParamSchema next to
params?. Needs = a new needs?: AppNeed[] array of data shapes the app
wants to consume from other apps ({ id, description, shape } — authoring
uses TS type refs; pack compiles them to the shape schema). There are no
predefined named interfaces: the kernel matches needs↔provides purely
structurally.
schemaSatisfies(provided, required)(schemaCompat.ts, pure) is the deterministic gate: a provider'sreturnsthat structurally satisfies a need'sshapeconnects free (binding: "exact"). It's conservative on purpose — same primitive types (integeralso satisfiesnumber), object/array recursion,requiredarrays honored, extra provided fields fine, unknown constructs fail closed with dotted-path reasons.- Otherwise the platform AI proposes a rename-only field map
(
fieldMap.ts, pure):FieldMapEntry { from, to }dotted paths with at most one[]array segment (e.g.meals[].macros.kcal→recipes[].nutrition.calories).validateFieldMaprejects transforms, unknown paths, and type-incompatible renames; the user confirms; the kernel applies it viaapplyFieldMap(binding: "ai-mapped", withconfidence).
App-facing API:
// Which provider actions satisfy my need? → ProviderMatch[]
const matches = await __conjureos.actions.discover("recipes-with-nutrition");
// [{ appPath, displayName, action, binding: "exact" | "ai-mapped", confidence? }]
// Invoke a provider; `normalize: <needId>` has the kernel apply the
// confirmed field map to the result (no-op for exact bindings).
const m = matches[0];
const data = await __conjureos.actions.invoke(m.appPath, m.action, {}, {
normalize: "recipes-with-nutrition",
});On the wire: actions.discover ({ type, id, needId }, guarded by
isActionsDiscover) answered by actions.discover.response
({ ok, matches?, error? }, mirroring actions.list), and an optional
normalize?: string on actions.invoke. All additive — 0.2 apps and kernels
are unaffected.
Wire compatibility rule
Wire shapes are frozen per semver minor. An app bundle is the same artifact on desktop and mobile, and old kernels meet new apps (and vice versa) all the time. Within a minor line, no shape may change at all; a new message type or a new optional field is at least a minor bump; anything that would break an existing app or kernel — removing/renaming a field, changing a type, making an optional field required — is a major bump. When in doubt, add a new message type instead of mutating one.
Consuming repos
- ConjureOS (desktop/web):
src/kernel/sandbox.tsre-exports the protocol types from here and callsbuildShim({ transport: "iframe", ... })instead of concatenating its per-bridge IIFEs. - conjureos-mobile:
src/bridge/protocol.tsandsrc/bridge/shim.tsbecome re-exports of this package (retiring the vendored copies and thecheck-protocol-driftpin).
Develop
npm install
npm run build # tsc → dist/ (ESM + .d.ts)
npm test # vitest: transport containment, ES5 checks, in-vm round trips