Extensions

jawb extensions: the built-in app.jawb.* extensions, jawb extension and jawb tool call, consent, runs and their limits, app.jawb.uat and app.jawb.screens, and publishing on jawb Hub.

An extension is a package that adds tools to jawb. Its tools appear in jawb tools beside the WebMCP tools a page declares, marked as coming from the extension, and you run them with jawb tool call. Every step an extension takes is an ordinary jawb command, so the same per-action policy, confirmations and trace apply: a cheaper route is never a weaker one.

Not in a release yet: extensions need jawbd at API level 18, and jawb's built-in extensions need level 20, with the jawb CLI after 0.1.1. jawb 0.1.1 and earlier have neither.

Version 0 runs one shape, workflow: a flat list of jawb steps that produces something, such as a report. There are two workflow extensions:

  • app.jawb.uat runs acceptance criteria against a site you develop and writes a report with a verdict per criterion.
  • app.jawb.screens screenshots pages of a site you develop at each viewport and colour scheme in one call.

Both are built into jawbd: their files are part of the jawbd binary you installed, so jawb extension install app.jawb.uat needs no download, and you still approve what it may do.

Extension ids are reverse-DNS names, like mobile app bundle ids: a domain the publisher controls, reversed, then the name (com.example.tool), or io.github.<user>.<name> without a domain. app.jawb.* is jawb's own and is only used by the built-in extensions.

Tool names belong to their extension: the full name is the extension id plus the tool's own name (app.jawb.uat.run, app.jawb.screens.capture), so two extensions never clash. jawb tool call also takes any shorter ending of the full name (uat.run, run) while only one installed tool matches it; when two do, it exits 2 and lists the full names.

Commands

jawb extension has the short alias jawb ext.

CommandWhat it does
jawb extension listInstalled extensions: id, version, how far jawb vouches for it ([built-in], [listed], [unlisted], [unlisted, was listed] or [dev]), enabled or disabled (and why), how consent was given, and their tools. Also the built-in extensions not installed yet, and the catalogue index's serial and expiry.
jawb extension info <id>One extension: its manifest, what it may do (origins, sessions, secrets, files) and its tools.
jawb extension install <id>[@<version>]A built-in extension (app.jawb.*) installs from the copy inside jawbd, with no download. Any other id: downloads the signed catalogue index and the package from get.jawb.app, hands them to jawbd. Either way it asks for your consent.
jawb extension install --file <pkg>A third-party extension from a .tar.gz on disk. It installs as listed when jawb's signed index lists these exact bytes, else as unlisted, and the consent says which. See Third-party extensions.
jawb extension install https://…/x.tar.gz#sha256=<hex>The same from an https URL. The #sha256= pin is optional; with it, a download that does not match is refused before jawbd sees it.
jawb extension load --dev <dir>Loads an unpacked extension directory for development: unsigned, labelled dev everywhere, never updated from the catalogue, and still needs your consent.
jawb extension remove <id>Removes it and its files. Asks first; --yes skips the question.
jawb extension refreshSends jawbd the newest signed index, so revocations reach it. Also jawb extension index. The daily background update check does this too when a daemon is running.
jawb toolsLists extension tools, plus the page's tools when there is a current tab (or --tab).
jawb tool call <name> --input <json|@file>Runs an extension tool.

jawbd never downloads anything for extensions. The CLI fetches the files and jawbd verifies them against the public keys compiled into it, so the trust decision is made in one place. A changed byte fails verification.

With --json every command prints the usual JSON envelope. An older jawbd is refused with E_VERSION_MISMATCH (exit 3).

Installing

Installing or loading an extension is held for you, the same way a purchase is (Policy and confirmation). Nothing is written until you approve.

On an interactive terminal, jawb shows what the extension may do and asks you to type install:

$ jawb extension install app.jawb.uat
jawb: installing an extension needs your consent (pc_8rwvaktb6c7p):
  extension: app.jawb.uat 0.1.0 from jawb (built into jawbd)
  purpose:   Run acceptance criteria against a site you develop and write a verdict report with evidence
  shape:     workflow
  origins:   http://localhost:*, http://127.0.0.1:*
  sessions:  incognito, profile:uat
  secrets:   uat.username, uat.password
  files:     write:jawb-uat
  network:
  - reads from http://localhost:*, http://127.0.0.1:* with the sessions incognito, profile:uat
  - sends nothing to other domains
  - writes files under jawb-uat (relative to the working directory)
  - fills the vault secrets uat.username, uat.password
  An extension never approves anything; purchase-like actions stay held for you.
Type `install` to approve it, or `deny` to decline it:

Where the operating system can verify you (Windows Hello, Touch ID), it asks too. Anywhere else (--json, a script, an agent) the command exits 5 with the pending id, and a person approves it later with jawb confirm <pc_id>, which also asks for install. --yes never approves an install. An update that asks for more (more origins, sessions, secrets, files or a higher effect ceiling) asks again and lists what widens.

Consent is "asserted" unless verified. jawb extension list shows consent verified only when the operating system verified the person who approved. Otherwise it says asserted: a program running as you could have approved it.

Third-party extensions

jawb extension install --file <pkg> and jawb extension install <https-url> install an extension someone else publishes. They need jawbd API level 28 and a current signed index from jawb, so jawbd can switch off a revoked package. jawb does not publish that index yet, so until it does these installs are refused (not_in_index); load --dev loads one you are developing.

jawbd labels each install by what jawb knows about the bytes:

  • listed: jawb's index lists these exact bytes. They passed jawb Hub's automated checks, which is not a review. The publisher shown is the listing's owner.
  • unlisted: jawb has not checked it. The consent shows where it came from, and the publisher named in its manifest is shown as a claim, not a fact.

An unlisted package cannot reuse a listed id with other bytes, and no third-party package may use jawb's name as its publisher or an app.jawb.* id. Every install pins its sha256. Any new version of a listed or unlisted extension asks you again, even one that asks for less, and so does the same package from another source. Only workflows install this way for now.

Running a tool

jawb tool call uat.run --input '{"scenario": "checks/home.toml"}'
jawb tool call uat.run --input @input.json

jawb tool call looks the tool up, registers a run with jawbd (which checks the input against the tool's schema), runs the steps, and ends the run. Every step carries the run's id, so jawbd:

  • refuses to open or act on an origin outside the extension's origins (outside_matches), including after a redirect;
  • refuses a session, a vault secret or an effect it was not granted (session_not_granted, secret_not_granted, exceeds_max_effect);
  • stops the run at its step and time limits (run_limit);
  • lets a run call only page commands: never configuration, profiles, secrets, cookies, storage or other extensions, and never an approval (outside_run_scope);
  • records the extension and the run on every trace step, labelled attribution: "client-claim" (jawb trace shows it).

The steps run in the CLI, as an ordinary client of jawbd. These checks bound what a step that names a run may do. They are not a sandbox.

Exit codes of jawb tool call:

ExitMeaning
0The run finished. Its verdicts are in the output and the report, whatever they are.
2Configuration error: bad input, an invalid scenario, an unknown or ungranted secret, an origin outside the extension's origins, an unusable output directory, an unknown tool, or a jawbd too old for extensions.
3Harness drift: jawbd is not running or was lost, the browser bridge is off or missing, or the site under test does not answer. No verdicts are published.

app.jawb.uat: acceptance tests with a report

app.jawb.uat runs the acceptance criteria of a scenario file against a site on localhost or 127.0.0.1, and writes report.md and report.json with one verdict per criterion, screenshots and trace ids.

Its grants: the origins http://localhost:* and http://127.0.0.1:*; an incognito tab per criterion, or the uat profile; the vault secrets uat.username and uat.password; files under ./jawb-uat in the working directory; at most 500 steps and 900 seconds per run; and the effect ceiling submit.

It is built into jawbd:

jawb extension install app.jawb.uat

Tools

  • app.jawb.uat.validate {scenario} checks a scenario and opens no page. It returns {ok, errors, unsupported_steps} and exits 0 when the scenario is valid, 2 when it is not.
  • app.jawb.uat.run {scenario, profile?, incognito?, out_dir?, stop_on_first_fail?, step_timeout_ms?} runs it.
    • scenario: a path to the scenario file (relative to the working directory), or the TOML inline.
    • profile: "uat" runs every criterion in the uat profile instead of incognito (needed for fill_secret).
    • out_dir: where the report goes, inside ./jawb-uat. The default is jawb-uat/<scenario id>. A path with .., or one that leads out of ./jawb-uat through a link, is a configuration error.
    • stop_on_first_fail: after a FAIL, the remaining criteria are reported UNCERTAIN, "not run".
    • step_timeout_ms: how long each expect_* step waits (default 10000).

Scenario format

A scenario is a TOML file: a [scenario] table and one [[criteria]] entry per acceptance criterion, each a flat list of steps. There are no loops, conditionals or references to an earlier step's output. An unknown key or step is an error, never ignored.

[scenario]
id = "issue-83-hero"
base_url = "http://localhost:3000"
issue = "4hum-ai/jawb#83"            # optional, copied into the report

[[criteria]]
id = "AC1"
text = "The home page shows the product name in the hero"
steps = [
  { open = "/" },
  { expect_text = "jawb" },
  { screens = { viewports = ["mobile", "desktop"], schemes = ["light", "dark"] } },
]

[[criteria]]
id = "AC2"
text = "Signing in with a valid account reaches the dashboard"
steps = [
  { open = "/login" },
  { fill = { target = { role = "textbox", text = "Email" }, value = "uat@example.test" } },
  { fill_secret = { target = { css = "input[name=code]" }, ref = "uat.username" } },
  { click = { text = "Sign in" } },
  { expect_url = "**/dashboard", timeout_ms = 5000 },
]

Each criterion runs in its own new tab, which is closed at the end whatever happens, so its first step must be open. A target is visible text ("Sign in") or a table with text and/or role, or css, plus nth when several elements match. Element ids are not accepted: they belong to one observation.

StepWhat it runs
open = "<path or URL>"open (the first step opens the tab), then open --tab for later ones. Paths join base_url.
click, hover, check, uncheck = targetThe Act command of the same name.
submit = true or targetsubmit.
fill = { target, value }fill.
fill_secret = { target, ref }fill --secret-from vault:<ref> in the run's profile. The value never reaches the CLI, the report or the trace.
type = { target, text, delay_ms? }type.
select = { target, value or values, by? }select (by = "label" to match labels).
press = "Enter" or { key, target? }press.
scroll = "top", "bottom" or { by = [x, y] }scroll.
expect_text, expect_element, expect_urlwait --for text: / element: / url: with the step timeout.
expect_no_text = "..."read --format text --full, then a check that the text is absent.
screens = { viewports, schemes }For each viewport (mobile, desktop or WxH) and scheme (light, dark): viewport, emulate, screenshot. At most 8 combinations.
screenshot = true or { full = true }screenshot.
capture = true or { full = true }read --format md; the first 2000 characters go into the report, marked as untrusted page content.

Any step may add timeout_ms. upload, download and media are known but not run in version 0; uat.validate lists them under unsupported_steps.

Verdicts

VerdictWhen
PASSEvery step of the criterion succeeded.
FAILAn expect_* step timed out, or expect_no_text found its text.
UNCERTAINA step could not run: no element matched, several matched, a stale or not actionable element after one re-observe, a timeout outside an expect_* step, or any other error.
BLOCKEDjawb held an action for a person (E_CONFIRMATION_REQUIRED) or its policy refused it (E_POLICY_DENIED). The report names the pending id and its class.

BLOCKED means the action was not approved during this run. The runner never approves anything, so BLOCKED is not evidence that a person saw it. A purchase-like click is always held, whatever the extension's ceiling. Filling a password field is a credential action, above uat.run's ceiling (submit), so a fill_secret into a password field is reported BLOCKED with exceeds_max_effect; use it for other fields.

Harness drift is not a verdict: when jawbd, the browser bridge or the site is missing, the run exits 3 and the report says harness_drift with the step log only.

The report

uat.run writes report.md and report.json into the output directory, next to its screenshots (<criterion>-<viewport>-<scheme>.png, <criterion>-step<n>.png).

report.md has a header (scenario, issue, base URL, jawbd version and API level, session, start and end times, the run's outcome and id), a table with one row per criterion (id, text, verdict, reason, evidence links, trace ids), and a step log per criterion.

report.json is the machine copy:

{
  "v": 1,
  "tool": "uat.run",
  "extension": {"id": "app.jawb.uat", "version": "0.1.0", "dev": false},
  "run_id": "r_dgrp3wd6xa4v",
  "attribution": "client-claim",
  "scenario": {"id": "smoke", "issue": null, "base_url": "http://127.0.0.1:4392", "source": "smoke.toml"},
  "session": "incognito",
  "jawbd": {"version": "0.1.1", "api_level": 18},
  "started_at": "2026-10-05T23:19:17Z",
  "ended_at": "2026-10-05T23:19:20Z",
  "duration_ms": 3120,
  "run": "completed",
  "exit_code": 0,
  "summary": {"PASS": 2, "FAIL": 1, "UNCERTAIN": 0, "BLOCKED": 0},
  "criteria": [
    {"id": "AC2", "text": "missing", "verdict": "FAIL",
     "reason": "expect_text: condition `text:Nope` not met within 800 ms",
     "evidence": [], "trace_ids": ["tr_01m475tjcngethv5sv", "tr_01m475tjct11netq93"],
     "steps": [{"n": 2, "command": "expect_text", "detail": "\"Nope\"", "outcome": "fail",
                "duration_ms": 813, "trace_ids": ["tr_01m475tjct11netq93"], "evidence": []}]}
  ]
}

Two runs of the same scenario against the same site give the same report.json once the fields that name a moment or an id are set aside: started_at, ended_at, every duration_ms, every trace id, run_id and pending_confirmation. A CI job that should fail on anything but PASS reads summary.

app.jawb.screens: screenshots across viewports and colour schemes

app.jawb.screens opens each page you name in a fresh incognito tab and takes one screenshot per viewport and colour scheme. By default that is mobile and desktop, light and dark: four PNGs per page.

jawb extension install app.jawb.screens
jawb tool call screens.capture --input '{"base_url": "http://localhost:3000", "pages": ["/", "/pricing"]}'

The files land in ./jawb-screens/screens/: home-mobile-light.png, home-mobile-dark.png, home-desktop-light.png, home-desktop-dark.png, the same four for pricing, and report.md and report.json. The output's pages lists each page with its verdict and its files, as paths relative to the working directory.

Its grants are:

  • the origins http://localhost:* and http://127.0.0.1:*;
  • an incognito tab per page, with no profile, so no cookies or sign-ins;
  • files under ./jawb-screens;
  • at most 500 steps and 900 seconds per run;
  • the effect ceiling act. It changes the viewport and the colour-scheme emulation and takes screenshots. It never clicks, fills or submits.

app.jawb.screens.capture {base_url, pages?, viewports?, schemes?, name?, out_dir?, step_timeout_ms?}:

  • base_url: the site's address.
  • pages: paths or URLs, 1 to 20 (default ["/"]). A page outside the origins above is a configuration error (exit 2), and nothing is opened.
  • viewports: mobile, desktop or WxH such as 390x844 (default ["mobile", "desktop"]).
  • schemes: light and/or dark (default both). There are at most 8 viewport and scheme combinations.
  • name: names the run and its folder (default screens).
  • out_dir: where the files go, inside ./jawb-screens.

A page is PASS when all its screenshots were written. A page that does not load is UNCERTAIN, with the reason. Exit codes are the ones in Running a tool.

Code extensions

A code extension runs its own JavaScript on Node.js, for what a list of jawb steps cannot do: parse a file, call a local tool, compare results. Its tools use the exec runner, and the package carries a bundle:

[runtime]
node = ">=22"
entry = "dist/extension.mjs"

[[tools]]
name = "summarise"
description = "Open a page and return its first line"
effect = "act"
max_effect = "act"
runner = "exec"
input_schema = "schemas/summarise.input.json"
output_schema = "schemas/summarise.output.json"

The entry module exports activate(ctx) and registers each tool the manifest declares:

export function activate(ctx) {
  ctx.registerTool("summarise", async (input, run) => {
    const opened = await run.jawb.open(input.url, { incognito: true });
    const t = opened.result.tab;
    const read = await run.jawb.tab(t).read({ format: "text" });
    await run.jawb.tab(t).close();
    return { first_line: read.result.content.split("\n")[0] };
  });
}

run.jawb.* is jawb's API for the call: each method is one request to jawbd, made by the CLI for this run, so the run's limits, origins, policy, confirmations and trace apply to it. A call after the tool has returned is refused (no_run). The tool's return value is checked against its output_schema before the call ends (invalid_output).

Code runs with your full access. It is a Node.js process with your user account's rights, not a sandbox: it can read your files and reach the network on its own. The manifest's grants ([matches], browser_sessions, [limits], max_effect) bind what it asks jawb to do through run.jawb.*, and jawb records and gates those calls, but for the code itself they are advisory: with your account's access it could read jawb's own files or drive a browser without jawb. Install code only from a publisher you trust. So installing a code extension says so ("runs code on this computer with full access to your user account"), and only an approval your operating system verifies (Windows Hello, Touch ID or your password) installs it; a typed install alone is refused (code_install_needs_verification). Any change to its files asks again. jawb extension list marks it [runs code: full user access].

How the CLI runs it:

  • Node.js 22 or later must be on your PATH (or set JAWB_NODE); otherwise the call exits 2 with runtime_missing.
  • Checked first. Before it starts the code, the CLI checks every file against the digests you approved, and refuses with package_modified when one changed.
  • A clean environment. The process gets only PATH (and, on Windows, SystemRoot, TEMP and TMP): no JAWB_* variable and no daemon token. It runs in its own working directory under %LOCALAPPDATA%\jawb-exthost (Windows) or ~/.cache/jawb-exthost, which jawb extension remove deletes.
  • One process per call for jawb tool call; jawb mcp keeps one per extension for the session. A call that passes [limits] max_run_seconds is stopped, and the process is killed together with everything it started. [limits] memory_mb caps its heap.
  • console.log goes to the CLI's log, never into the tool's output or the trace.

Not yet: hooks (ctx.registerHook throws until #198), and activation conditions beyond "a tool was called". Code extensions need jawbd at API level 35 and a jawb CLI after 0.1.1.

Publish your extension on jawb Hub

Community extensions are listed on jawb Hub, a public GitHub repository, and shown on jawb.app/extensions. Listed means the exact bytes passed jawb Hub's automated checks. It is not a review, a signature or a warranty: jawb does not vouch for the code. The listing terms apply. The author guides live there too: a tutorial, Build and list your first extension, the manifest reference, and a template to copy, examples/site-screens.

  1. Host your extension yourself, as a release .tar.gz (a GitHub release asset, for example) or an npm package. Your code stays in your repository, under your licence.
  2. Prove you own its id.
    • For io.github.<you>.<name>, your GitHub account is the proof.
    • For a domain id (com.example.hello names example.com), publish github:<your login>, either in https://example.com/.well-known/jawb-hub.txt or as a DNS TXT record _jawb-hub.example.com.
  3. Open a pull request that adds one file, listings/<id>.toml, with the id, its owners, a homepage, and for each version its source and the sha256 of the exact file.
  4. The checks run on the pull request:
    • fetch the package from its source and compare its sha256;
    • the package rules;
    • the manifest, as jawbd checks it;
    • hidden or bidi characters in anything a model reads;
    • the never-list;
    • ownership of the id.

    A maintainer merges a green pull request without reviewing your code, and the listing appears on jawb.app. A listed version never changes: publish a new version instead.

Once a listing is merged, jawb extension install <id> installs it from jawb's signed index, labelled listed. install <url> and install --file take the same bytes from anywhere (see Third-party extensions).

What comes later

These parts of the extension design are planned, not built.

  • More shapes. Site tool packs (a script in the matching tab, in its own isolated world) and app connectors or API tools (jawbd adds a stored secret at the network layer; the extension never sees it). A stored secret will carry the domains it may be sent to, so an extension cannot move it elsewhere. Two more shapes are reserved: policy packs, which can only make jawb's decisions stricter, and readers, which turn jawb's own view of a page into structured data.
  • More third-party sources. Installing from npm and from a git repository, besides a file and a URL (built, waiting for jawb's signed index).
  • jawb's signed index. jawb Hub's listings will reach jawbd in a signed index with a short expiry, which also carries a revocation list, by version or by the package's sha256. A revoked package is switched off and its data kept. An out-of-date index stops new installs but never blocks browsing. jawbd already enforces all of this; jawb does not publish the index yet.
  • The desktop app. Discovering, installing and removing extensions in the jawb desktop app, from the same signed catalogue the CLI uses.
  • Hooks. Code extensions subscribing to what happens in jawb (a page loaded, an action done, a tool called), from the same events jawb trace --follow --json shows.
  • An isolated host. A WebAssembly Component host (Wasmtime) where extension code gets only what its manifest grants, with per-plugin quotas, for extensions that do not need full access.
  • Free and paid. Free and paid extensions are planned. jawb itself is free for personal developers; business and team use will need a paid licence.

What jawb will never list

  • LinkedIn data extraction or outreach tools. LinkedIn's terms name browser add-ons.
  • Coupon auto-appliers at checkout.
  • CAPTCHA solvers, and anything that evades rate limits or bot defences.
  • Generators that pass a second factor unattended.
  • AI sidebar clones: your agent's own model does that job.

The catalogue also refuses rules that loosen anything, hooks that change how the browser launches or what every page sees, native binaries, and extension code inside the jawb desktop app.

See Roadmap and status for what is built and what is planned.