Skip to content

Writing a craft ​

A craft is one piece of logic reachable from the browser, a headless runtime, and AI. craftsmith is the CLI you use to create, check, build, and publish one — it scaffolds the project, reads your code with the TypeScript type checker, and emits a shippable package.

This guide goes end to end: scaffold with craftsmith, write your logic once in tools.ts, then add whichever faces you need — AI tools (JSDoc), a browser UI (index.html), a headless runtime (runtime.ts) — and finally check, build, and publish.

Install & scaffold ​

bash
npm i -D logisheets-craftsmith     # the CLI
npx craftsmith new my-craft        # scaffold a project
cd my-craft && npm install

craftsmith new writes a ready-to-build project:

my-craft/
├── package.json     # identity: craftId, label, version (+ check/build scripts)
├── tools.ts         # YOUR LOGIC — pure functions (the core)
├── index.html       # a browser UI (optional)
└── tsconfig.json

The file that matters most is tools.ts — your logic. Everything else is a thin face over it. runtime.ts isn't scaffolded; add it yourself when you want the runtime face, and craftsmith build picks it up automatically.

Identity: package.json ​

Three fields identify your craft:

json
{
  "craftId": "my-craft",   // stable, kebab-case — the id the host installs under
  "label": "My Craft",     // human name shown in the craft picker
  "version": "0.1.0"
}

That's all the host needs. When you publish, it installs your package by craftId and reads the generated manifest.json — you don't register the craft anywhere by hand.

The core: your logic in tools.ts ​

A craft's logic is a set of plain, exported, pure functions. "Pure" here means: takes what it needs through parameters, returns a value, and touches no window, document, or globals. That's the property that lets the same function serve your UI, the AI, and a unit test.

Each function's first parameter is ctx — the host-injected context (workbook client, etc.). Everything after ctx is the function's own input.

ts
import type {SkillCtx} from 'logisheets-craftsmith/authoring'

export async function writeCell(
    ctx: SkillCtx,
    row: number,
    col: number,
    text: string
): Promise<{written: string}> {
    await ctx.workbook.handleTransaction({
        transaction: {
            payloads: [
                {type: 'cellInput', value: {sheetIdx: 0, row, col, content: text}},
            ],
            undoable: true,
            temp: false,
        },
    })
    return {written: text}
}

The ctx object ​

FieldWhat it is
ctx.workbookThe live LogiSheets client — the full read surface (getCell, getCells, getAllSheetInfo, getCellInfos, …) plus handleTransaction.
ctx.workbook.getVersion()A number that bumps on every committed write. Use it for read-then-write safety.
ctx.craftStateOptional get() / set() for this craft's own saved JSON. Only for state not already in the sheet.
ctx.confirm(msg)Ask the user to approve; resolves true if they do.
ctx.log(msg)Write a progress line into the chat transcript.
ctx.signalAn AbortSignal that fires if the user cancels the turn.

Face: AI tools (JSDoc) ​

To expose your functions to Watson (the built-in AI assistant), annotate them with JSDoc. craftsmith reads the annotations and the TypeScript signature to generate the capability manifest — so it can never drift from your code. There are no runtime decorators; it's all plain comments.

The annotations ​

Two levels: one skill block at the top of the file (what the craft is for), and one @tool block per exported function.

ts
import type {SkillCtx} from 'logisheets-craftsmith/authoring'

/**
 * @logicianSkill Budget helper: fills and balances a monthly budget. Use when
 *   the user asks to set up, fill, or rebalance a budget.
 * @guidance Call count_sheets first if you need the sheet count.
 */

/**
 * @tool Write text into a cell on the first sheet.
 * @param row  Zero-based row index.
 * @param col  Zero-based column index.
 * @param text The text to write.
 * @mutates true
 * @confirm always
 */
export async function writeCell(
    ctx: SkillCtx,
    row: number,
    col: number,
    text: string
): Promise<{written: string}> {
    /* … */
    return {written: text}
}

/**
 * A read-only tool — keep the defaults (@mutates none, @confirm never).
 * @tool Report how many sheets the workbook has.
 */
export async function countSheets(ctx: SkillCtx): Promise<{sheets: number}> {
    const infos = (await ctx.workbook.getAllSheetInfo()) as unknown[]
    return {sheets: infos.length}
}
AnnotationWhereMeaning
@logicianSkill <text>once, top of fileWhat the craft is for and when Watson should use it. This is the line Watson sees when browsing installed crafts.
@guidance <text>once, optionalExtra how-to injected when Watson picks this craft (combining tools, gotchas).
@tool <text>each exported fnMakes the function a callable tool. The text tells the model when to call it.
@param name <text>per argumentDescribes an argument (shown to the model).
@mutates none|temp|trueper toolDoes it change the sheet? Default none.
@confirm never|once|always|destructiveper toolAsk the user before running? Default never for @mutates none, always otherwise.

Descriptions are for the model

Write @logicianSkill and @tool text as "what and when", not implementation notes — that phrasing is exactly how Watson decides whether to reach for your craft and which tool to call.

Signature rules ​

craftsmith infers the input schema from the real signature, so it enforces:

  • Named export only — export function foo / export const foo = …. No anonymous export default (there'd be no stable name to dispatch to).
  • First parameter is ctx — named ctx or typed *Ctx / *Context. It is excluded from the tool's input.
  • JSON-serializable parameter/return types — string, number, boolean, string-literal unions (→ an enum the model must choose from), arrays, and plain object shapes of those. Anything the checker can't serialize is a craftsmith check error telling you to simplify it.
  • Pure / ambient-free — no window, document, or top-level side effects. craftsmith check lints for this.

How Watson uses your craft ​

Watson discovers crafts progressively, so its tool list stays small no matter how many are installed:

  1. discover — it lists installed crafts and each one's @logicianSkill line, and picks the one that fits the request.
  2. use — it loads that craft's tools (and injects your @guidance).
  3. invoke — it calls a tool; dispatch runs your function with a ctx whose workbook is permission-scoped to your craft.

Read-then-write safely ​

If a tool reads state, computes, then writes, the user could change the sheet in between — and your write would clobber their edit. Snapshot getVersion() before the read, re-check before the write, and retry if it moved:

ts
export async function solve(ctx: SkillCtx): Promise<{ok: boolean}> {
    for (let attempt = 0; attempt < 4; attempt++) {
        const v0 = await ctx.workbook.getVersion()
        const board = await readBoard(ctx.workbook)         // your read
        const result = compute(board)                        // pure
        if ((await ctx.workbook.getVersion()) !== v0) continue // changed → retry
        await write(ctx.workbook, result)
        return {ok: true}
    }
    return {ok: false}
}

(The engine has no compare-and-swap, so this optimistic check is the right tool.)

Say what your block is for, and keep it yours ​

A block your craft creates should carry two things beyond its schema.

A description. The schema says what shape the records are; nothing else says what they mean. It is saved in the file and reported by getBlockInfo, so it is what Watson — or you, next session — reads to understand a table it did not build. Write it for a stranger, not for this conversation.

Per-operation permissions. One policy for the whole block is too blunt: you usually want the user to keep typing into your table while refusing to let them pull rows out from under it or re-point its schema, either of which takes the block out of your control for good.

ts
await ctx.workbook.handleTransaction({
    transaction: {
        payloads: [
            {
                type: 'createBlock',
                value: {
                    sheetIdx, id: blockId,
                    masterRow: 0, masterCol: 0, rowCnt: 8, colCnt: 3,
                    owner: 'my-craft',              // required for any policy to bite
                    description:
                        'One row per run. `score` is computed by my-craft; ' +
                        'edit `input`, never `score`.',
                    modifyPolicy: 'ownerAndUser',   // the fallback
                    permissions: {
                        cellInput: 'all',           // let anyone type
                        insertDeleteLines: 'ownerOnly',
                        removeBlock: 'ownerOnly',
                        modifySchema: 'ownerOnly',
                        // sortByField left out → follows modifyPolicy
                    },
                },
            },
        ],
        undoable: true, temp: false,
    },
})

The operations are insertDeleteLines, removeBlock, modifySchema, cellInput, sortByField, modifyDescription and overrideValidation. Each takes all, ownerAndUser (the person at the keyboard, but no other craft) or ownerOnly. An operation you leave out follows modifyPolicy. owner must be set — a policy on an unowned block has nobody to privilege, so it is read as all.

overrideValidation is the narrow one: it governs writing a value that a field's validationFormulas rule rejects, not writing at all. Left at all a violating value lands and the cell is flagged, which is what every block did before the op existed. Reserved to the owner, the person at the keyboard is held to the rule while you can still seed a row you know is incomplete — a required field you fill in on the next round. Hosts ask checkFieldValidation({sheetIdx, row, col, proposed}) for the verdict on a value before writing it, then mayModifyBlock with this op; the engine evaluates the rule against the proposed value without touching the cell.

removeBlock is worth reserving even on a block that is otherwise open: deleting it takes the records, the schema and this policy along with it, and there is no editing your way back. Moving a block is not governed separately — it keeps its identity, its cells and its rules wherever it lands.

Two things to understand about enforcement:

  • The engine does not enforce it. A payload carries no trace of who prompted it, so the core cannot tell your write from the user's. Whoever offers an operation is what must refuse it.
  • Ask, do not re-implement. Call mayModifyBlock({sheetIdx, blockId, op, actor}) — actor is 'user' or {type: 'craft', value: 'my-craft'}. The decision lives in the core so the app, the desktop build, Watson and your craft cannot drift apart on what a policy means.
ts
const ok = await ctx.workbook.mayModifyBlock({
    sheetIdx, blockId, op: 'insertDeleteLines',
    actor: {type: 'craft', value: 'my-craft'},
})
if (isErrorMessage(ok) || !ok) return {ok: false, why: 'not mine to resize'}

Both can also be set later, on a block that already exists, with the setBlockDescription and setBlockPermissions payloads. setBlockPermissions replaces the whole set rather than patching it, so state the block's full stance each time — that is the only way to clear an override back to "follows the default".

Face: a browser UI ​

An index.html gives your craft a UI in the craft panel. It's loaded in a same-origin <iframe>; after it loads, the host injects window.workbook and friends onto it. Your page imports the built ./tools.js and calls the same functions Watson calls.

html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8" /><title>My Craft</title></head>
<body>
  <button id="go" type="button" disabled>Write into A1</button>
  <script type="module">
    import {writeCell} from './tools.js'

    // Host APIs arrive asynchronously — poll for window.workbook before use.
    function whenReady(cb) {
      let n = 0
      ;(function loop() {
        if (window.workbook) return cb()
        if (n++ > 600) return console.warn('host APIs never arrived')
        setTimeout(loop, 50)
      })()
    }

    // Build the same ctx shape Watson passes to your tools.
    const ctx = () => ({
      workbook: window.workbook,
      signal: new AbortController().signal,
      confirm: async () => true,
      log: (m) => console.log('[my-craft]', m),
    })

    const btn = document.getElementById('go')
    btn.addEventListener('click', () => {
      writeCell(ctx(), 0, 0, 'Hello from a craft 👋')
        .then(() => window.notifyCraft?.('success', 'Done'))
        .catch((e) => window.notifyCraft?.('error', String(e)))
    })
    whenReady(() => { btn.disabled = false })
  </script>
</body>
</html>

Host API (injected on window) ​

Everything below appears after the iframe loads — guard with whenReady.

  • window.workbook — the same Client your tools use: the full read surface plus handleTransaction. The EditPayloads you'll use most:

    PayloadShape
    write a value/formula{type:'cellInput', value:{sheetIdx, row, col, content}}
    style a cell{type:'cellStyleUpdate', value:{sheetIdx, row, col, ty}}
    create a sheet{type:'createSheet', value:{idx, newName}}
    delete a sheet{type:'deleteSheet', value:{idx}}
    set column width{type:'setColWidth', value:{sheetIdx, col, width}}
    set row height{type:'setRowHeight', value:{sheetIdx, row, height}}
  • Selection — window.selection (a {sheetIdx, data} snapshot), window.onSelectionChange(cb) (returns a disposer), window.setSelection(sheetIdx, row, col), and window.setSelectionSuppressed(true) to hide the highlight (painting/game crafts use this).

  • Canvas input — window.onCanvasInput(cb) intercepts mouse/keyboard on the grid before the engine sees it. cb gets {type, sheetIdx, row, col, …}; return true to consume the event, false/undefined to pass it through.

  • Zoom — the grid already zooms itself on Ctrl/⌘ + wheel and on a trackpad pinch, so a craft doesn't need to implement the gesture. Use window.setCanvasZoom(factor) / window.getCanvasZoom() only to drive a specific factor (e.g. a "fit the board" button).

  • Persistence — window.setCraftState(json) / getCraftState() for per-document state the host folds into the saved workbook; window.craftStorage (async key/value) for device-scoped preferences that persist across documents.

  • Host UI — window.notifyCraft('success'|'info'|'warn'|'error', msg) shows a toast; window.setCellLayouts([...]) overlays markers on cells.

  • Language — window.locale is the language the host is speaking (a BCP-47 tag: 'en', 'zh-CN'), and window.onLocaleChange(cb) calls back when the user switches (returns a disposer). The host also puts the same tag in your page's URL (?lang=zh-CN), which is readable before you have painted anything. See below.

Speaking the host's language ​

The host states which language it is in; your craft decides what to do about it. A craft with one language ignores both APIs and keeps working — nothing breaks, it just stays as it is.

To follow along, keep a dictionary and re-render on change. crafts/lights-out is the worked example: text nodes carry data-i18n="key", and one function swaps them.

js
var STRINGS = {
  'zh-CN': {title: '关灯', newGame: '新游戏'},
  en: {title: 'Lights Out', newGame: 'New game'},
}
// NOT `var locale`: at the top level of a craft page that IS `window.locale`,
// the very property the host injects. Same binding, so the host's next
// inject() would silently reassign your state without re-rendering — and your
// applyLocale would write back over the host's value.
var craftLocale = 'en'

/** What the host asked for in this page's URL, before it has injected. */
function urlLocale() {
  try {
    return new URLSearchParams(location.search).get('lang') || undefined
  } catch (e) {
    return undefined
  }
}

function applyLocale(tag) {
  // Take the nearest language you actually have: the host may report `zh-CN`
  // when you only wrote `zh`, and it will report languages you never wrote.
  var want = tag || urlLocale()
  craftLocale = STRINGS[want] ? want : String(want || '').startsWith('zh') ? 'zh-CN' : 'en'
  document.querySelectorAll('[data-i18n]').forEach(function (el) {
    el.textContent = STRINGS[craftLocale][el.getAttribute('data-i18n')]
  })
}

// At the END of <body>, so every [data-i18n] node exists: paint in the right
// language on the first frame, from the URL alone.
applyLocale()

whenReady(function () {
  applyLocale(window.locale)
  if (window.onLocaleChange) window.onLocaleChange(applyLocale)
})

Four things worth getting right:

  • Don't name your own state locale. A top-level var locale in a craft page is window.locale — the property the host injects and re-injects on every pass. The two silently overwrite each other, and the symptom is a craft whose text half-switches.
  • Register inside whenReady, not at DOMContentLoaded. The iframe's DOMContentLoaded fires before the host has injected anything, so window.locale is still undefined there.
  • But translate from ?lang= before that, or your page flashes. Your markup is written in one language, and waiting for window.locale means waiting for the load event, the host's inject() and your own whenReady poll — tens of milliseconds, well past the first paint. A host speaking the other language visibly saw the wrong one first. The URL parameter is there while the page is still parsing: call applyLocale() straight from the inline script at the end of <body> (measured: language applied ~22ms in, first paint at ~44ms). It also means a craft opened standalone with ?lang=en renders in English. Anything that hook re-renders must tolerate being called before your state exists — guard on the state, not just on the module.
  • Names the workbook stores need an alias table before you translate them. A sheet name is user-visible (it is the tab the player reads), so it deserves to follow the language — but it is also how your craft finds its own data again. Translate it only behind a table of every name you have ever shipped, look up by membership in that table, and sheetRename the sheet you find instead of creating a second one. Drop a name from the table and every document saved under it is orphaned. lights-out is the worked example: BOARD_NAMES / BOARD_ALIASES / boardName(locale) / findBoardSheet. Names a FORMULA refers to — block ref names, field names — are a harder case, because saved formulas hold the old string: leave those in one language unless you also rewrite every formula that mentions them.
  • Your panel label is separate. The name in the craft picker comes from crafts.config.json, where label may be a string (one name everywhere) or {"en": "...", "zh-CN": "..."}. Both spellings ship in every build and the panel picks one at render, so the list follows a language switch too.

Face: a headless runtime ​

A craft can also run without any UI. Add a runtime.ts that default-exports a CraftRuntime — the host reconstructs it from the workbook's saved state and calls lifecycle hooks around each JSON-RPC exchange. This is how a craft runs server-side (the Node runtime, the collaboration server), e.g. as a validation gateway that vets edits before they commit.

The hooks fire in order around one exchange:

HookWhenReturn
onLoad(state, wb)once, when the workbook opensrehydrate from state
onRequest(req, state, wb)a request's inputs are about to be appliedreject to block the request
onValidate(state, wb)inputs are in place, before the response is readthe cells that fail (empty = all good) — optional
onResponse(resp, state, wb)the response is about to be returnedinspect / annotate

Every hook returns a Result<T>: a plain value (or undefined) on success, or an ErrorMessage ({msg, ty}) to reject. A non-empty onValidate result tells the host to reject the request and roll the inputs back.

ts
// runtime.ts
import type {
    CraftRuntime,
    Violation,
    JsonRpcRequest,
    JsonRpcResponse,
} from 'logisheets-craftsmith/authoring'

// Narrow the craft's persisted state to your own shape.
interface GatewayState {
    statusCol: number
    allowed: string[]
}

export default {
    // Rehydrate when the workbook opens. Nothing to precompute here.
    onLoad(_state: GatewayState, _wb) {
        return
    },

    // Let inputs be applied; we check them in onValidate below.
    onRequest(_req: JsonRpcRequest, _state: GatewayState, _wb) {
        return
    },

    // Inputs are now written — flag any status cell with a disallowed value.
    async onValidate(state: GatewayState, wb) {
        const violations: Violation[] = []
        for (let row = 1; row < 100; row++) {
            const cell: any = await wb.getCell({
                sheetIdx: 0,
                row,
                col: state.statusCol,
            })
            const value = cell?.value?.value
            if (value == null || value === 'empty') continue
            if (!state.allowed.includes(String(value))) {
                violations.push({
                    sheetIdx: 0,
                    row,
                    col: state.statusCol,
                    kind: 'membership',
                    message: `"${value}" is not an allowed status`,
                })
            }
        }
        return violations // empty = accept; non-empty = host rejects & rolls back
    },

    onResponse(_resp: JsonRpcResponse, _state: GatewayState, _wb) {
        return
    },
} satisfies CraftRuntime<GatewayState>

onValidate is optional — omit it for a runtime that only reacts to requests. craftsmith build compiles runtime.ts to runtime.js and records it in the manifest automatically; there's nothing else to wire.

Face: asking the AI (@aiRole) ​

The other three faces all run inward — something else calls your craft. This one runs outward: your craft puts a question to a model and gets a schema-valid answer back. A board asking for the opponent's move, a simulator asking what to look at.

Declare the question on the interface that describes the reply:

ts
/**
 * @aiRole coach
 * @system You are a Lights Out coach. Read the current status and the
 *   solver's suggested cell with the tools available to you, then explain in
 *   two or three sentences why clicking that cell helps.
 */
export interface CoachExplanation {
    /** Two or three sentences, addressed to the player. */
    explanation: string
    cell?: {row: number; col: number}
}

Then ask, from your UI:

js
const status = await window.craftAi.available()
if (!status.ok) return disableAiFeature(status.reason) // 'no-key' | 'denied' | …

const {explanation} = await window.craftAi.ask('coach', 'What should I click?')

The model reads through your tools ​

You do not serialize state into the question. input is the question; the model reaches for your craft's own @tool exports to read whatever it decides it needs, then answers. So a craft that already exposes tools gets this for free — the declaration above is the entire addition.

It is offered your @mutates none tools and nothing else: no mutating tool, no workbook surface, none of Watson's. The model gathers; your craft acts. That is what keeps an ask safe to abandon halfway — and it means the answer is a proposal your craft still validates:

js
if (!isLegal(uci)) return notifyCraft('warn', `The AI proposed ${uci}.`)
AnnotationWhereMeaning
@aiRole <name>on an exported interface / type aliasDeclares a question. <name> is what ask() selects; kebab-case.
@system <text>with @aiRoleWho the model is while answering. Sent as written, with line breaks collapsed to spaces.

The reply schema comes from the declaration itself, and each property's doc comment becomes that field's description — that is how the model learns what to put in it, so write them.

Why it is declared and not passed ​

system could have been an argument to ask(). Declaring it means the consent prompt can show the user the exact words your craft will send, craftsmith check can validate it, and craftsmith build can emit craft-roles.d.ts so ask('coach', …) is typed as CoachExplanation without you restating it.

There is no conversation

Each ask is independent — nothing is remembered between calls. That is deliberate: your craft's state is already in the workbook, so the model reads the live position every time instead of replaying a history that can go stale. To correct a bad answer, ask again with the correction in the question.

What the user sees ​

The first time your craft calls ask, the host asks the user to allow it and shows them your @system text. They can decline (available() then returns denied), and the panel header shows an indicator while a question is in flight. A craft with no @aiRole cannot call ask at all.

Check, build, publish ​

bash
npx craftsmith check .   # validate the tool contract + purity — no writes
npx craftsmith build .   # compile tools.ts/runtime.ts → dist/ + manifest.json
npx craftsmith pack  .   # tar dist/ into a shippable <craftId>-<version>.tgz

build produces dist/:

dist/
├── manifest.json   # the generated contract (see below) — never hand-edit
├── tools.js        # your logic, bundled self-contained (deps included)
├── runtime.js      # present iff you added runtime.ts
└── index.html      # present iff you added a UI

The manifest is what the host reads to discover and drive your craft. It is a generated build artifact — run craftsmith check in CI to guarantee it matches your code:

jsonc
{
  "schemaVersion": 1,
  "craftId": "my-craft",
  "version": "0.1.0",
  "label": "My Craft",
  "url": "index.html",           // present iff you have a UI
  "rtJs": "runtime.js",          // present iff you have a runtime
  "skill": { "description": "…", "guidance": "…" },
  "tools": [
    { "name": "write_cell", "description": "…",
      "inputSchema": { /* from your types */ },
      "paramOrder": ["row","col","text"],
      "entry": "tools.js", "export": "writeCell",
      "mutates": true, "confirmation": "always" }
  ]
}

Publishing is shipping that dist/: publish your package to the craft registry your deployment uses. Once installed, the host lists your craft by craftId, loads tools.js for Watson, index.html for the panel, and runtime.js for the server — no change to the host required.