The conciv extension-authoring contract: defineExtension/defineTool plus the SolidJS
runtime context and typed useSlot/useContext hooks.
Part of conciv. Author an extension under
conciv/extensions/*.tsx in your app:
import {defineExtension} from '@conciv/extension'
export default defineExtension({
name: 'my-extension',
// tools, slots, context…
})An extension can declare registry tools whose bodies run in the browser (where the widget is
mounted, with access to live client state, the DOM, or a framework fiber) and reach them from its
server half through the tool registry. There is no page-only vocabulary: a browser capability is
an ordinary registry tool with a fully qualified name, dispatched to the page by that name over the
{requestId, name, input} wire.
A declaration is registry-grade when it carries meta (with a summary) and an outputSchema.
Keep the declaration in a shared module; the server half registers it with a body-less
.client() (core forwards calls to the page), and the client half binds the browser body with
.client(body):
// shared/defs.ts
import {z} from 'zod'
import {defineTool} from '@conciv/extension/tool'
export const routerStateDef = defineTool({
name: 'demo.routerState',
description: 'read the live router path off the page',
inputSchema: z.object({}),
outputSchema: z.object({path: z.string()}),
meta: {summary: 'read the live router path off the page', category: 'demo'},
})// server.ts — declaration only; the registry forwards the call into the browser
import {defineExtension} from '@conciv/extension'
import {routerStateDef} from './shared/defs.js'
export default defineExtension({name: 'demo', tools: [routerStateDef.client()]}).server((server) => {
async function currentPath() {
const state = await server.tools.call('demo.routerState', {})
return state
}
return {context: {currentPath}}
})// client.ts — the browser body, collected off the mounted extension instance
import {defineExtension} from '@conciv/extension'
import {routerStateDef} from './shared/defs.js'
export default defineExtension({
name: 'demo',
tools: [routerStateDef.client(() => ({path: location.pathname}))],
}).client(() => ({value: {}}))Bodies are (input, ctx): the input arrives schema-validated, and the dispatcher builds ctx per
call — ctx.document, a lazy ctx.target(locator) that resolves a {ref, selector, name} locator
(and fires the action mirror when the declaration sets meta.mirrors), a non-throwing
ctx.resolve(locator), ctx.addRef/ctx.resetRefs for snapshot refs, and ctx.consoleEntries().
Return a plain JSON-serializable record; declare meta.mutating: true and the call is journaled and
prompts for user approval on every surface.
Only tools declared by extensions the widget actually mounted are dispatchable — a failed client mount contributes no browser tools.
server.tools.call goes through the tool registry, and the registry converts every page failure
into one of the tool's declared transport errors before rejecting — a typed oRPC error whose
code is uppercase. By the time your catch runs there is no PageVerbError left to guard for;
match on the declared code instead:
| Declared code | Meaning |
|---|---|
NO_PAGE_CLIENT |
No widget is connected, so the body cannot run in any browser. |
PAGE_TIMEOUT |
A widget is connected but never replied within the page-bus timeout. |
UNKNOWN_TOOL |
No mounted extension declares a client tool by that name. |
INVALID_ARGS |
The arguments failed the tool's zod schema, or the target is missing. |
HANDLER_ERROR |
The body threw, or returned a non-JSON-serializable value. |
A toolError(code) thrown by the browser body whose code the declaration lists under errors
is rebuilt as that declared error instead of HANDLER_ERROR.
function declaredCode(error: unknown): string | null {
if (typeof error !== 'object' || error === null || !('code' in error)) return null
return typeof error.code === 'string' ? error.code : null
}
try {
await server.tools.call('demo.routerState', {})
} catch (error) {
if (declaredCode(error) === 'NO_PAGE_CLIENT') {
// degrade gracefully — nothing is looking at the page
}
}isPageVerbError still exists, but it only fires on the raw page seam — server.page.call,
the unwrapped browser ask that skips the registry (used, for example, by a server handler that
wraps its own client body). There the rejection is a lowercase-coded PageVerbError
(no-widget, timeout, unknown-verb, invalid-args, handler-error).
When a tool's execute awaits server.tools.call, the tool part stays in its running state
until the call resolves or rejects, so the card renders a loading state and then a result — or, if
the call rejects, an error card (the rejection propagates out of execute and surfaces as the
tool part's output-error state). A failed page verb never renders as a green success. Do not catch
and swallow a PageVerbError inside execute if you want the failure reflected in the card; let it
reject.