# Sutram: all documentation pages > One set of commands for your web app: people type them in a console, AI agents call them as WebMCP tools or from JavaScript. Every part of the app registers its commands the same way, and they run in the user's own session. --- Source: https://d34dman.github.io/sutram/docs/index.md # Sutram documentation Sutram lets a web app expose what it can do as commands. You register a command once: a name, a JSON Schema for its input, and a function. Agents can then call it as a WebMCP tool, from JavaScript in a headless browser, or by typing into the page's console, and people can use the same console. WebMCP is how a page hands tools to an agent; Sutram decides what those tools are, so every part of the app exposes its commands the same way. See [Why not just WebMCP?](why.md#why-not-just-webmcp). ```js Sutram.register({ name: "cart add", provider: "cart", description: "Add a product to the cart", input: { type: "object", properties: { sku: { type: "string" }, qty: { type: "integer", default: 1 } }, required: ["sku"], }, annotations: {}, run: ({ sku, qty }) => cart.add(sku, qty), }); ``` That is all most apps need to write. Help pages, the console, the WebMCP tools and input checking come from the definition. ## Your path 1. [Quick start](quick-start.md): one command, in an HTML file, in five minutes. 2. [Install](install.md): a script tag or npm. On Drupal, use the [module](drupal.md). 3. [Write a provider](guides/write-a-provider.md): a real set of commands for one part of your app, with a checklist at the end. 4. [Write descriptions agents can use](guides/descriptions.md) and [errors that help agents](guides/errors.md): the two things that make the biggest difference to how well agents do. When you want the details of a definition, read [Commands and providers](concepts/commands.md). To decide which commands ask the person first, read [Annotations and the policy](concepts/annotations-and-policy.md). ## How agents reach your commands You don't set these up one by one; loading Sutram does. Read these to see what agents get. - [WebMCP](guides/webmcp.md): tools in browsers that support it. - [The console](guides/console.md): for people, and for agents that type. - [Hand a task to a browser agent](guides/agent-prompt.md): `agentPrompt()`. - [Discovery: help, man, status](concepts/discovery.md). - [Test with Playwright](guides/testing.md): call your commands the way a headless browser does. ## Going further Only when you need it: | When | Read | |---|---| | Your app has forms an agent should fill in | [Forms](guides/forms.md), [Drafts and saving](guides/drafts-and-saving.md) | | Several changes should run as one, with one question | [Batches](concepts/batches.md) | | You want to know what a result looks like, or what an exit code means | [Results and exit codes](concepts/results.md) | | A value has a domain format, such as money or a date range | [Formats](concepts/formats.md) | | Agents should hear about things that happen in the app | [Events](concepts/events.md) | ## Reference - [API](reference/api.md), generated from the TypeScript declarations - [The command line](reference/command-line.md): grammar, options, exit codes, reserved words ## About - [Why commands](why.md): why small commands beat reading the page, with [measurements](measurements.md). - [Design notes](https://d34dman.github.io/sutram/docs/design.md), for contributors. - For agents: [llms.txt](https://d34dman.github.io/sutram/llms.txt), and the skills in the package's `skills/` folder. --- Source: https://d34dman.github.io/sutram/docs/quick-start.md # Quick start ## 1. One command Save this as an HTML file and open it in a browser: ```html Hello, Sutram

Press ` and type help.

``` A command is a name, a JSON Schema for its input, annotations, and a function. `provider` says who owns it; `help` groups commands by it. ## 2. The console Press ` (backtick) and try: ``` › help › hello world › hello world Ada › hello world --who="Grace Hopper" › hello world -h › man hello world ``` You wrote no help text and no parser. `help`, `man`, `-h`, option parsing, completion with Tab, and "did you mean" come from the definition. ## 3. What an agent sees There is nothing more to set up. In a browser with WebMCP, `hello_world` is already a tool. To see it the way an agent does, open the browser's developer tools and run: ```js await Sutram.call("hello_world", { who: "Ada" }) // → { code: 0, text: "Hello, Ada." } Sutram.tools() // → [ …, { name: "hello_world", description: "Say hello.", // inputSchema: {…}, annotations: { readOnlyHint: true } } ] ``` `Sutram.call()` is also how a headless browser, a browser agent or a test calls your commands. The list starts with two tools of Sutram's own, `sutram_status` and `sutram_batch`; you don't need to do anything for them. ## Next - Write the commands for one part of your app: [Write a provider](guides/write-a-provider.md). - Using npm and a bundler: [Install](install.md). On Drupal: [Drupal](drupal.md). - Hand a task to an agent that drives a browser (Claude Code, Codex, Claude Desktop, Claude in Chrome): [Hand a task to a browser agent](guides/agent-prompt.md). --- Source: https://d34dman.github.io/sutram/docs/install.md # Install ## One script, no bundler Load `dist/sutram.js` from a CDN, or from the package. It registers your commands as WebMCP tools where the browser supports them, and exposes everything as `globalThis.Sutram`. Mount the console where you want it: ```html ``` Pin the minor version, as above. `dist/sutram.min.js` is the same code without comments and indentation, about a quarter smaller. Names are not shortened, so stack traces still read. ## npm ```sh npm install @d34dman/sutram ``` With a bundler, import the registry, the console and WebMCP: ```js import { getRegistry } from "@d34dman/sutram"; import { mountConsole } from "@d34dman/sutram/console"; import { connectWebMCP } from "@d34dman/sutram/webmcp"; const registry = getRegistry(); registry.register({ /* a command */ }); // The console, for people. mountConsole(); // The same commands as WebMCP tools, where the browser supports them. connectWebMCP(registry); ``` Unlike the one-script build, the modules do nothing until you call them, so remember `connectWebMCP()`. TypeScript declarations ship with the package (`types/`), for every entry point and for `globalThis.Sutram`. ## Drupal `composer require drupal/sutram`, then enable the module. It ships the package build; nothing is loaded from a CDN. See [Drupal](drupal.md). ## Details ### Entry points You need the first three, or only the one-script build. The others are for special cases. | Import | What it is | Needs a DOM | |---|---|---| | `@d34dman/sutram` | What app authors use: the registry, formats, the policy, `commandLine()`, `agentPrompt()` | no | | `@d34dman/sutram/console` | ``, the console, as a web component | yes | | `@d34dman/sutram/webmcp` | Mirrors the registry into `document.modelContext` as WebMCP tools | no | | `@d34dman/sutram/events` | Events your app declares and emits, and the `events list`, `events recent` and `events wait` commands | no | | `@d34dman/sutram/client` | For a client of your own: parse and complete command lines, check input, run status, format results | no | | `@d34dman/sutram/browser` | `globalThis.Sutram`: the registry and its helpers, with `/client` as `Sutram.client` | yes | | `@d34dman/sutram/dist/sutram.js` | The same as `browser`, bundled into one file; `sutram.min.js` stripped | yes | | `@d34dman/sutram/nav` | The [nav kit](guides/nav-kit.md): `nav status`, `menu`, `search` and `go` over menus your adapter supplies | no | | `@d34dman/sutram/dist/nav.js` | The nav kit as a file of its own, for pages that load `dist/sutram.js`; adds `Sutram.kits.nav` | yes | | `@d34dman/sutram/page` | The [page kit](guides/page-kit.md): `page status` from your adapter | no | | `@d34dman/sutram/user` | The [user kit](guides/user-kit.md): `user status`, `login` and `logout` | no | | `@d34dman/sutram/messages` | The [messages kit](guides/messages-kit.md): `messages status` and `message.shown` events | no | | `@d34dman/sutram/lists` | The [lists kit](guides/lists-kit.md): `listing status`, `rows` and `go` over listings your adapter describes | no | | `@d34dman/sutram/forms` | The [forms kit](guides/forms-kit.md): `form list`, `status`, `fields`, `get`, `set`, `submit` and `discard`, and `add`, `remove`, `search` and `upload` where your adapter has them | no | | `@d34dman/sutram/dist/page.js` · `user.js` · `messages.js` · `lists.js` · `forms.js` | Those kits as files of their own, like `dist/nav.js`; add `Sutram.kits.page`, `.user`, `.messages`, `.lists` and `.forms` | yes | | `@d34dman/sutram/internals` | Helpers Sutram uses itself. No semver promise | no | ### Several copies on one page If two scripts each bundle their own copy of Sutram, they still share one registry, one format registry and one event bus, so every command ends up in the same console and the same tool list. That holds for copies of the same registry version: 0.4 and later do not share a registry with 0.3 or earlier, and warn when both are on a page. Load one version. --- Source: https://d34dman.github.io/sutram/docs/drupal.md # Drupal [`drupal/sutram`](https://www.drupal.org/project/sutram) ships the Sutram library and opens the console for users with the *Use Sutram* permission. It registers no Drupal commands of its own; other modules do that, and its demo submodules show how. ```sh composer require drupal/sutram drush en sutram ``` Give the *Use Sutram* permission to the roles that should have it. Then, on any page, press ` (the key left of 1), or Ctrl+` inside a text field, and type `help`. The library is shipped in the module (`lib/sutram`); nothing is loaded from a CDN. ## Demo submodules They are demos, not a stable API: try them, or copy them as a starting point. - **`sutram_demo_nav`**: `nav status`, `nav menu`, `nav search` and `nav go` over the menus you choose. Each user sees only the links they may open. The commands come from the [nav kit](guides/nav-kit.md) (the library `sutram/nav`); the submodule is only its adapter. - **`sutram_demo_page`**: `page status`, the page's title, route and entity, from the [page kit](guides/page-kit.md) (`sutram/page`). - **`sutram_demo_user`**: `user status`, `user login` and `user logout`, from the [user kit](guides/user-kit.md) (`sutram/user`). `user login` opens Drupal's login page; the person types the password. The submodule also emits `user.changed` when the page comes back as someone else. - **`sutram_demo_messages`**: Drupal's status messages, after a page load or an AJAX request, as `messages status` and `message.shown` events, from the [messages kit](guides/messages-kit.md) (`sutram/messages`). - **`sutram_demo_lists`**: Views listings on the page, such as the content overview, as `listing status`, `listing rows` and `listing go`, from the [lists kit](guides/lists-kit.md) (`sutram/lists`): rows, pages, table sorts and exposed filters. - **`sutram_demo_form`**: fill in and submit content forms, an adapter for the [forms kit](guides/forms-kit.md) (`sutram/forms`). See below. - **`sutram_demo_canvas`**: build pages in the [Drupal Canvas](https://www.drupal.org/project/canvas) editor. See below. ### Forms The form commands follow the [forms guide](guides/forms.md). They are registered once and look the form up when they run, so a form that arrives by AJAX (in a dialog, say) works too, and a form that has gone answers "not on this page". Fields are dotted paths such as `title` or `body.value`. `set` writes into the real inputs (and CKEditor), so the form on screen and the commands agree. `submit` clicks the real Save button: Drupal validates, checks access and runs its hooks as usual, and errors come back in `form status
`, by field. ```text form fields node-article-form form set node-article-form title "Pour-over, step by step" form set node-article-form field_tags "coffee, v60" form set node-article-form status false form submit node-article-form ``` ### Canvas In the Canvas editor, the `canvas` commands add, change, move and remove components, and set the page's title, URL alias and description. They go through Canvas itself, so every change shows in the preview, can be undone and saves as a draft. ```text canvas components canvas page set title "Our coffee" canvas add heading --as=top canvas set top text "Fresh every week" canvas add card --after=top --as=intro canvas set intro title "Single origin" ``` Pasted together, these lines run as one [batch](concepts/batches.md): if one fails, Canvas's undo takes back the others. `--as` names a new component so later lines can refer to it. `canvas tree` shows the page, `canvas undo` takes back the last change. `canvas publish` is off by default. A site builder turns it on at *Configuration › Development › Sutram Demo Canvas*, and it is offered only to users who may publish in Canvas. It asks every time. ## Add commands from your module Define a library that depends on `sutram/sutram`, attach it where the commands make sense, and register them: ```yaml # mymodule.libraries.yml commands: js: js/mymodule.sutram.js: { attributes: { type: module }, preprocess: false } dependencies: - sutram/sutram ``` ```js // js/mymodule.sutram.js globalThis.Sutram.register({ name: "cache clear", provider: "cache", description: "Clear all caches", input: { type: "object", properties: {} }, annotations: { consequential: true }, run: async () => { /* call one of your module's own routes */ }, }); ``` Pass what the commands need from PHP in `drupalSettings`, as the demo form module does. Commands run in the browser with the user's session, so they can do only what the user can already do. Check access on the server, in the routes your commands call, as you would for a button. ## Try it The [demo site](https://github.com/d34dman/drupal-sutram-demo) installs Drupal with the module, its submodules and Canvas in DDEV, with an empty Canvas page to build from the console. --- Source: https://d34dman.github.io/sutram/docs/guides/write-a-provider.md # Write a provider A provider is the part of your app that owns some commands. This guide builds the cart from the [coffee shop demo](https://d34dman.github.io/sutram/#demo): status first, then reading, then changing, then the one command that needs a person's yes. The full code is in `site/app.js`. ## 1. Status first Start with what an agent needs before anything else: what state is this in? ```js const NONE = { type: "object", properties: {} }; Sutram.register({ name: "cart status", provider: "cart", description: "Show what is in the cart", input: NONE, output: { type: "object", required: ["count", "total", "items"], properties: { count: { type: "integer" }, total: { type: "number", format: "money" }, items: { type: "array", items: LINE }, }, }, view: { columns: ["sku", "name", "qty", "price"] }, annotations: { readOnly: true }, seeAlso: ["cart add", "checkout"], run: () => { if (!cart.size) { return { text: "The cart is empty. Try shop list, then cart add.", data: { count: 0, total: 0, items: [] }, }; } return { text: `${summary()}.`, data: { count: count(), total: total(), items: items() }, }; }, }); ``` - It is read-only, so nobody is ever asked before it runs. - `text` is one line for people; `data` is the state, for agents and for `--json`. - The empty case says what to do next. Agents follow hints like that. - A bare `status` (and the agent's `sutram_status`) now includes the cart. ## 2. A change ```js Sutram.register({ name: "cart add", provider: "cart", description: "Add a product to the cart", input: { type: "object", properties: { sku: { type: "string", enum: products.map((p) => p.sku), description: "Product code, from shop list", }, qty: { type: "integer", minimum: 1, maximum: 20, default: 1, description: "How many", }, }, required: ["sku"], }, positional: ["sku", "qty"], output: { type: "object", required: ["count", "total"], properties: { count: { type: "integer" }, total: { type: "number", format: "money" }, }, }, annotations: {}, examples: [ "cart add V60-02", "cart add GUJI-250 2", "cart add SCALE --qty=1", ], seeAlso: ["cart remove", "cart status", "checkout"], run: ({ sku, qty }, { caller }) => { cart.set(sku, (cart.get(sku) || 0) + qty); changed(caller); return { text: `Added ${qty} × ${bySku.get(sku).name}. The cart has ${summary()}.`, data: { count: count(), total: total() }, }; }, }); ``` - The schema does the checking. `cart add V61` fails with exit 2 before `run()` is called, and the message lists the valid codes. - `enum` also gives completion with Tab for free. - `positional` makes `cart add V60-02 2` work, as well as `--sku=V60-02 --qty=2`. - `run()` calls the app's own code. The shop's buttons call the same command, so people, the console and agents all go one way. ## 3. A command that only exists when it makes sense ```js let removeCheckout = null; function toggleCheckout() { if (cart.size && !removeCheckout) { removeCheckout = Sutram.register({ name: "checkout", provider: "cart", description: "Place the order for everything in the cart", input: NONE, annotations: { consequential: true }, confirm: () => `Place an order for ${summary()}?`, run: () => { /* … */ }, }); } else if (!cart.size && removeCheckout) { removeCheckout(); removeCheckout = null; } } ``` - `checkout` appears in `help` and in the agent's tools once the cart has something in it, and disappears when it is empty. Nobody calls a command that cannot work. - `consequential: true` means the policy asks first. `confirm` words the question with the real numbers. ## 4. Failures that help ```js run: ({ sku }) => { if (!cart.has(sku)) { const message = `cart remove: ${sku} is not in the cart.`; throw new CommandError(message, EXIT.FAILED); } // … }, ``` Say which command failed and why, in words the caller can act on. See [Errors that help agents](errors.md). ## 5. Check it ``` › help cart › man cart add › cart add V61 › cart add V60-02 2 › status › checkout ``` Then the agent's view: `Sutram.tools()` in the developer tools, and `await Sutram.call("cart_add", { sku: "V60-02" })`. See [Test with Playwright](testing.md) to make that a test. ## Checklist - [ ] A read-only ` status` with one line of `text` - [ ] Names start with the provider: `cart add`, not `add to cart` - [ ] Descriptions say what the command does, in one line - [ ] Every input property has a type, and a `description` where the name is not obvious - [ ] `annotations` set on purpose: read-only, consequential, navigates or reinitializes (and `reinitializes()` called where it happens) - [ ] Commands registered only while they make sense - [ ] Failures name the command and say what to do instead - [ ] `examples` for anything with more than one argument --- Source: https://d34dman.github.io/sutram/docs/guides/nav-kit.md # Nav kit A kit is a set of commands that Sutram ships ready-made. It gives every app the same command names, so an agent that has found its way around one site can find its way around the next. The kit registers the commands, matches what was typed, writes the errors and offers completion. Your app supplies an **adapter**: the data, and the one action the kit cannot do itself. The nav kit gives you four commands: | Command | Does | Annotations | |---|---|---| | `nav status` | Where this page is in the menus | read-only | | `nav menu [menu]` | The menus, or one menu's links with their path and description | read-only, `untrustedContent` | | `nav search ` | Links whose title or description contains all the words | read-only, `untrustedContent` | | `nav go ` | Goes to a link by path, title or breadcrumb, or to any path on the site | `navigates` or `reinitializes` | `nav go` takes a path only when it leads to this site: it resolves the path the way the browser would and refuses one that ends up elsewhere, such as `/\example.com`. The adapter's `go()` gets the resolved path. ## The adapter ```js import { nav } from "@d34dman/sutram/nav"; nav({ // The menus, keyed by name. Called each time a command runs. menus: () => ({ main: { label: "Main", items: [ { title: "Home", url: "/" }, { title: "Docs", url: "/docs", description: "How to use the app", children: [{ title: "Install", url: "/docs/install" }], }, ], }, footer: { label: "Footer", items: [{ title: "Contact", url: "/contact" }] }, }), // Goes to a URL. Called after nav go has answered. go: (url) => location.assign(url), // Does going load a new page? reloads: true, }); ``` | Adapter key | Required | What it is | |---|---|---| | `menus()` | yes | The menus as `{ [name]: { label, items } }`. Each item has a `title` and a `url`, and may have an `id`, a `description` and `children`. It may return a promise. | | `go(url)` | yes | Goes to the URL. The kit calls it after `nav go` has delivered its result, so the caller always gets an answer. Return `false` (or a promise of it, or reject) when the page did not leave, such as when a router handled it in place or the person stayed; Sutram then resumes on this page at once. | | `reloads` | yes | `true` if going loads a new page, `false` for a client-side router. | | `here()` | no | The current path, such as `/admin?tab=2`. The default is `location.pathname + location.search`. | `nav()` returns a function that removes the four commands again. It refuses an adapter without one of the required keys, and it registers nothing if a `nav` command already belongs to another provider. ### Only the links the user may open The kit shows whatever `menus()` returns. Leave out links that the current user cannot open, the way your app's menus do, so that people and agents see the same site. ### Ask, don't cache The kit calls `menus()` every time a command runs, and again for completion. Keep it cheap: return data the page already holds. Because the kit never keeps a copy, a menu that changes (after a login, say, or a route change) shows up in the next command without any work on your side. ## Full page loads or a router `reloads` decides how `nav go` is marked, so that agents know what happens to the page: - **`reloads: true`**: `nav go` is marked [`navigates`](../concepts/annotations-and-policy.md). The page is left, and Sutram starts again on the new one. - **`reloads: false`**: `nav go` is marked `reinitializes`. Your router shows the new route, and then your app calls `Sutram.initialized()` (or `registry.initialized()`). Until then, runs are refused with exit 130, so no command acts on the route that was left. Call it even when `go()` is asked for the route that is already showing: ```js const here = () => location.hash.slice(1) || "/"; addEventListener("hashchange", () => { render(); Sutram.initialized(); }); nav({ menus: () => menus, go: (url) => { if (url === here()) Sutram.initialized(); else location.hash = url; }, reloads: false, here, }); ``` The [example page](https://d34dman.github.io/sutram/example/) uses this adapter, with two static menus. ## What `nav go` accepts In this order: 1. A link's path or `id`: `nav go /admin/content`. 2. A link's title, in any case: `nav go "content types"`. 3. A breadcrumb, which may leave out the top of the trail or start with the menu's label or name: `nav go Structure › Menus`, `nav go Footer › About`. 4. Any path that starts with a single `/`, like following a link to it. `//host` and full URLs to other sites are refused. If a title matches links with different paths, `nav go` fails and lists them, so the caller can pick the breadcrumb. `nav go` is not consequential: it does what following a link does. So any same-site URL that changes something when it is opened (a GET logout link without a confirmation form, a "delete" link, a one-click action) can be reached through it without asking, even where the command for it, such as `user logout`, is consequential. Keep such actions behind a form or a POST, as the web expects. (`go()` cannot refuse them: it runs after `nav go` has answered.) ## Without a bundler `dist/nav.js` is the kit as a file of its own. Load it after `dist/sutram.js`, which it takes the registry from, and call `Sutram.kits.nav()`: ```html ``` In Drupal, `drupal/sutram` ships it as the library `sutram/nav`, and `sutram_demo_nav` is the adapter. See [Drupal](../drupal.md). --- Source: https://d34dman.github.io/sutram/docs/guides/page-kit.md # Page kit The page kit gives you one command, `page status`. It says what the page is: its title, its route and the thing it shows, such as an article. Agents run it first on every page (`status` runs every provider's status command), so they know where they are before they act. | Command | Does | Annotations | |---|---|---| | `page status` | The page's title, path, route and the thing it shows | read-only, `untrustedContent` | ## The adapter ```js import { page } from "@d34dman/sutram/page"; page({ // What this page is. Called each time page status runs. page: () => ({ title: document.title, route: "article", entity: { type: "node", id: "5", bundle: "article", label: "Hello" }, }), }); ``` | Adapter key | Required | What it is | |---|---|---| | `page()` | yes | `{ title, route?, entity? }`. `entity` is what the page shows: `{ type, id, bundle?, label? }`. It may return a promise. | | `here()` | no | The current path, such as `/node/5?page=2`. The default is `location.pathname + location.search`. | `route` is the app's own name for the kind of page, such as Drupal's `entity.node.canonical`. Leave out what your app does not have. ```text > page status "Hello", route article. It shows node 5 (article): "Hello". ``` The kit asks every time, so a client-side router needs no extra work: return what the current route shows. Only describe what the user may see. ## Without a bundler Load `dist/page.js` after `dist/sutram.js` and call `Sutram.kits.page()`. In Drupal, `drupal/sutram` ships it as the library `sutram/page`, and `sutram_demo_page` is the adapter. See [Drupal](../drupal.md). --- Source: https://d34dman.github.io/sutram/docs/guides/user-kit.md # User kit The user kit gives you three commands: | Command | Does | Annotations | |---|---|---| | `user status` | Whether you are logged in, and as whom | read-only | | `user login` | Opens your app's login, and comes back to this page after | `navigates` or `reinitializes` | | `user logout` | Logs out, and comes back to this page | `navigates` or `reinitializes`, consequential | A password never passes through a command. `user login` only opens your login; the person types the password there, and the agent carries on as that user. ## The adapter ```js import { user } from "@d34dman/sutram/user"; const back = () => encodeURIComponent(location.pathname + location.search); user({ // Who is logged in. Called each time a command runs. user: () => ({ authenticated: true, id: "7", name: "ada", roles: [{ id: "editor", label: "Editor" }] }), // Called after the command has answered. login: () => location.assign(`/login?destination=${back()}`), logout: () => location.assign(`/logout?destination=${back()}`), // Do logging in and out load a new page? reloads: true, }); ``` | Adapter key | Required | What it is | |---|---|---| | `user()` | yes | `{ authenticated, id?, name?, roles? }`. Roles are ids, or `{ id, label }`. It may return a promise. | | `login()` | yes | Opens the login and comes back to this page. The kit calls it after `user login` has answered, and only when nobody is logged in. Return `false` (or reject) when the page did not leave, such as when the person stayed; Sutram then resumes on this page at once. | | `logout()` | yes | Logs out and comes back. Called after `user logout` has answered, and only when someone is logged in. Return `false` (or reject) when the page did not leave. | | `reloads` | yes | `true` if logging in and out loads a new page; `false` if your app stays on the page, as with a login dialog. | Secrets your app needs to log out, such as a CSRF token, stay in the adapter: the kit never sees them. With `reloads: false`, the commands are marked `reinitializes`. Show the new user, then call `Sutram.initialized()`, as for a router in the [nav kit](nav-kit.md#full-page-loads-or-a-router). ## Noticing a change of user The kit keeps nothing between pages, like the rest of Sutram. If your app wants to tell agents that someone else is now logged in, it compares with what it remembered and emits an event of its own. The Drupal module does that: it emits `user.changed`. ## Without a bundler Load `dist/user.js` after `dist/sutram.js` and call `Sutram.kits.user()`. In Drupal, `drupal/sutram` ships it as the library `sutram/user`, and `sutram_demo_user` is the adapter. See [Drupal](../drupal.md). --- Source: https://d34dman.github.io/sutram/docs/guides/messages-kit.md # Messages kit After a save or a page change, apps show messages: "Saved", or what went wrong. People read them on screen; the messages kit hands them to agents in two ways, fed by one adapter function: - `messages status` lists them, for an agent that asks. A bare `status` (and `sutram_status`) includes it, with how many there are and the latest. - Each message is emitted as a `message.shown` event, for an agent that reads `events recent` or waits for one with `events wait message.shown`. | Command | Does | Annotations | |---|---|---| | `messages status` | The messages this page has shown, oldest first | read-only, `untrustedContent` | When a command starts Sutram again, the note agents get says to look again with `status` once the page is ready, which includes the messages. ## The adapter ```js import { messages } from "@d34dman/sutram/messages"; const shown = []; // your app adds to it when it shows a message messages({ // The messages shown since the page loaded, oldest first. messages: () => shown, // Optional: say when new messages may have come without a new page. watch: (changed) => { app.onMessage = changed; return () => { app.onMessage = null; }; }, }); ``` | Adapter key | Required | What it is | |---|---|---| | `messages()` | yes | `[{ type, text }]`, where `type` is `status`, `warning` or `error`. It may return a promise. | | `watch(changed)` | no | Calls `changed()` when new messages may have come without a page load, such as after an AJAX request. May return a function that stops watching. | The kit asks `messages()` when the command runs, once when the kit is set up, and after each `changed()`. It emits `message.shown` for the messages there are at the start, and after each `changed()` for those that are new: the ones after the list it saw last, or all of them when the list starts afresh (after a route change, say). ```text > messages status 1 message; the latest (status): Article Hello has been updated. TYPE TEXT status Article Hello has been updated. ``` Give the text as people read it, without HTML. ## Without a bundler Load `dist/messages.js` after `dist/sutram.js` and call `Sutram.kits.messages()`. In Drupal, `drupal/sutram` ships it as the library `sutram/messages`, and `sutram_demo_messages` is the adapter. See [Drupal](../drupal.md). --- Source: https://d34dman.github.io/sutram/docs/guides/lists-kit.md # Lists kit Listings are where agents find what to work on: the content overview, the people, the orders. The lists kit reads them. It changes nothing on the rows themselves; it shows another page, sort or filter. | Command | Does | Annotations | |---|---|---| | `listing status` | The lists on this page: rows shown, page, sort, and the sorts and filters each offers | read-only, `untrustedContent` | | `listing rows [list]` | The rows a list shows now, one value per column, with each row's link | read-only, `untrustedContent` | | `listing go [list] --page --sort --order --filter` | Shows the list at another page, sort or filter, all in one go | `navigates` or `reinitializes` | ```text > listing status Content (content): 50 rows of 132, page 1 of 3, sorted by changed desc. See listing rows. > listing go content --filter=type=article,status=1 --sort=title Showing Content, page 1, sorted by title asc, filtered by type=article, status=1. Then run listing rows. ``` A new sort or filter starts at page 1. `--filter` takes `key=value` pairs, separated by commas; `key=` clears a filter. Tab completes lists, sorts, filter keys and the values of filters that are a choice. ## The adapter ```js import { lists } from "@d34dman/sutram/lists"; lists({ // The lists on this page. Called each time a command runs. lists: () => ({ content: { label: "Content", columns: [{ key: "title", label: "Title" }, { key: "type", label: "Type" }], rows: [{ title: "Hello", type: "Article", url: "/node/5" }], page: 1, pages: 3, total: 132, sort: "changed", order: "desc", sorts: [{ key: "title", label: "Title" }, { key: "changed", label: "Updated" }], filters: [{ key: "type", label: "Type", value: "", options: [{ value: "article", label: "Article" }] }], }, }), // Shows a list in a new state. Called after listing go has answered. go: (list, { page, sort, order, filters }) => { const q = new URLSearchParams({ ...filters, page: page - 1, order: sort, sort: order }); location.assign(`${location.pathname}?${q}`); }, reloads: true, }); ``` | Adapter key | Required | What it is | |---|---|---| | `lists()` | yes | The lists, keyed by name. Each has `columns` and `rows`; `page` (from 1), `pages`, `total`, `sort`, `order`, `sorts` and `filters` are optional. A filter has a `key`, its `value` now (`""` when not set) and, for a choice, `options`. It may return a promise. | | `go(list, state)` | yes | Shows the list in `state`: `{ page, sort?, order?, filters }`. The state is complete (filters not changed keep their value), so the adapter only builds its URL or query. Return `false` (or reject) when the page did not leave, such as when your app handled it in place; Sutram then resumes on this page at once. | | `reloads` | yes | `true` if `go()` loads a new page; `false` if your app shows the list in place. Then call `Sutram.initialized()` once it does, as for a router in the [nav kit](nav-kit.md#full-page-loads-or-a-router). | Give the values as people read them, without HTML. A row's `url` is where its link goes, such as the item it lists; pass it to `nav go`. The kit only offers what `sorts` and `filters` list, so agents cannot ask for a sort the list does not have. Leave out rows and filters the user may not see, as the list on screen does. ## Without a bundler Load `dist/lists.js` after `dist/sutram.js` and call `Sutram.kits.lists()`. In Drupal, `drupal/sutram` ships it as the library `sutram/lists`, and `sutram_demo_lists` is an adapter for Views. See [Drupal](../drupal.md). --- Source: https://d34dman.github.io/sutram/docs/guides/forms-kit.md # Forms kit The forms kit gives every form the command set of the [forms guide](forms.md): the same names, inputs and messages on every site, so an agent that has filled one form knows them all. The app's adapter says which forms there are, reads and fills their fields, and sends them. The kit checks names, converts values, tracks unsaved changes and writes the messages. It never touches the page. | Command | Does | Annotations | Registered | |---|---|---|---| | `form list` | The forms on the page, with unsaved changes and errors; with one form, its fields too | read-only, `untrustedContent` | always | | `form status [form]` | Unsaved changes, required fields still empty, errors. Without a form: the page's only form, or one line for each of several, so `status` has one | read-only, `untrustedContent` | always | | `form fields ` | Every field: path, label, type, required, value, options, format | read-only, `untrustedContent` | always | | `form get ` | One field in full, with its options or format | read-only, `untrustedContent` | always | | `form set ` | Change one field; names the fields the answer adds or removes | `untrustedContent` | always | | `form submit ` | Send it, the same as its button; checks required fields first | consequential, `navigates` if it reloads | always | | `form discard ` | Put back the values the form loaded with | consequential | always | | `form add [value]` | Add an item to a field that takes several, or a value to what it holds | `untrustedContent` | with `add` | | `form remove ` | Remove an item or a file | consequential | with `remove` | | `form search ` | What a reference field can point to | read-only, `untrustedContent` | with `search` | | `form upload [source]` | Put a file into a file field, from a URL, `--text` or `--base64` | consequential | with `upload` | ## The adapter ```js import { forms } from "@d34dman/sutram/forms"; const values = { email: "", topic: "" }; forms({ // The forms on the page now. Called each time a command runs. forms: () => [{ id: "contact", label: "Contact", fields: [ { field: "email", label: "Email", type: "string", required: true, value: "" }, { field: "topic", label: "Topic", type: "choice", options: { billing: "Billing", bug: "Bug" }, value: "" }, ], }], read: (form, field) => values[field.field], write: (form, field, value) => { values[field.field] = value; render(); }, submit: (form) => { send(values); return { text: "Sent. Thank you." }; }, reloads: false, }); ``` | Adapter key | Required | What it is | |---|---|---| | `forms()` | yes | The forms on the page now: `{ id, label?, entity?, fields, errors? }`. Each field has a dotted `field` path, a `type` and `value`, what it held when the form loaded; unsaved changes are measured from it. `label`, `required`, `options` (for choices) and `format` are optional. | | `read(form, field)` | yes | The field's value now. | | `write(form, field, value)` | yes | Fills the field with a value the kit (or your type) converted. Fill the real input and fire its events, so the form on screen and the commands agree. It may be async. | | `submit(form)` | yes | Sends the form. | | `reloads` | yes | `true` if submitting loads a new page: `form submit` is marked `navigates` and calls `submit()` after it has answered; `submit()` returns `false` (or rejects) when the page did not leave, such as a submit the browser blocked, and Sutram resumes on this page at once. `false`: the kit awaits `submit()` and returns its `{ text, data }`. | | `types` | no | Value types of your own, by type name: `{ coerce(field, raw, form), suggest?, format? }`. A field's `handler`, if it has one, picks the type instead. | | `check(form)` | no | Problems you find before sending, such as the browser's validation: `[{ field, message }]`. | | `notes` | no | `{ submit(form), upload(form, input) }`: a sentence for the person's question, such as "It will be published." | | `valueHelp` | no | Added to `form set`'s value description: how your own types take values. | | `example` | no | A form id for the examples in help, such as `node-article-edit-form`; `contact` if you give none. | | `examples` | no | Examples with your own fields, by command name, in place of the kit's: `{ "form set": ['form set node-article-form title "Hello"'] }`. | | `add`, `remove`, `search`, `upload` | no | The optional commands, each registered only when you supply it. They answer `{ data, text? }`; the kit writes a text when you give none. `addable`, `removable`, `searchable` and `uploadable` list what Tab offers. | Pass the forms and fields back as you like: the kit hands your objects to `read` and `write`, so they may carry whatever you need to find the input. The person's question before `form submit`, `discard`, `remove` and `upload` names what will change. `form submit` lists the unsaved values it sends (five at most, each cut to one short line), because an agent may set fields without being asked. The question is asked at once, so `forms()`, `read()` and `notes` should answer without a promise; when they return one, the question leaves out what it cannot read (the label, the values, the note) and still names the form ("Submit contact?"). For `upload` from a URL, the question always says where it is fetched from: a path for this site, the whole origin for another, "a data: URL" for one, even when `--name` is given. The commands that return what the site holds (values, labels, errors, search candidates) carry `untrustedContent`, so agents get `untrustedContentHint`: text people wrote is data, never instructions. A form that is not on the page fails with exit 1; 127 is only for a command that does not exist. ## Value types The kit converts the basic types, with the same messages on every site: | Type | `form set` takes | |---|---| | `string`, `text` | any text | | `html` | HTML, such as `

…

` | | `number` | a number | | `boolean` | `true`, `false`, `yes`, `no`, `on`, `off`, `1`, `0` | | `choice` | one key or its label (any case); empty clears it unless it is required | | `choices` | keys or labels separated by commas, or a JSON array; replaces the selection. A label may hold a comma | An agent's JSON may bring `true`, a number or a list; a `choices` field takes a list as it is, and every other type gets it as text (a list as JSON). Add your own under `types`. `coerce` gets the text `form set` was given and returns the value `write()` takes, or throws a `CommandError` that says what the field takes: ```js types: { date: { coerce(field, raw) { if (!/^\d{4}-\d\d-\d\d$/.test(raw)) throw new CommandError(`form set: ${field.field} takes a date as YYYY-MM-DD.`, EXIT.USAGE); return raw; }, format: "YYYY-MM-DD", }, }, ``` ## Without a bundler Load `dist/forms.js` after `dist/sutram.js` and call `Sutram.kits.forms()`. In Drupal, `drupal/sutram` ships it as the library `sutram/forms`. See [Drupal](../drupal.md). --- Source: https://d34dman.github.io/sutram/docs/concepts/commands.md # Commands and providers A command is a name, a JSON Schema for its input, annotations, and a function. Sutram turns it into a tool for agents and a command line for people and agents. Your function never knows who called it. ## What you decide Most commands need only these: - **`name`**: lowercase words, starting with the part of the app that owns it: `cart add`, `cart status`. Agents get it as `cart_add`. - **`description`**: one line that says what it does. Agents choose commands by it, so [write it for them](../guides/descriptions.md). - **`input`**: a JSON Schema, checked before your function runs. Leave it out when there are no arguments. - **`annotations`**: does it change anything? `{ readOnly: true }` if not, `{ consequential: true }` if it is hard to undo (the person is asked first), `{}` for an ordinary change. - **`run`**: your function. Return `{ text, data }`, or throw to fail. `provider` names the owner too, usually the first word of the name; `help` groups commands by it. Everything else below is optional. ## Every field ```js import { getRegistry } from "@d34dman/sutram"; const remove = getRegistry().register({ // " ", lowercase words. name: "cart add", // Who owns it; help groups by this. provider: "cart", // Optional: a short name for people, in approval prompts and tool lists. title: "Add to the cart", description: "Add a product to the cart", // JSON Schema, checked before run() is called. input: { type: "object", properties: { sku: { type: "string", description: "Product code" }, qty: { type: "integer", minimum: 1, default: 1 }, }, required: ["sku"], }, // cart add V60-02 2 is the same as cart add --sku=V60-02 --qty=2 positional: ["sku", "qty"], // Optional: drives tables, man and agents. output: { type: "object", properties: { count: { type: "integer" }, total: { type: "number" }, }, }, // Required, even when empty. annotations: {}, // Shown in man and given to agents. examples: ["cart add V60-02 --qty=2"], seeAlso: ["cart status"], // Optional: values for Tab completion. suggest: (key, partial) => { if (key !== "sku") return []; return catalog.search(partial).map((p) => ({ value: p.sku, description: p.name, })); }, run: async ({ sku, qty }, { caller, signal }) => { // Your app's own code and endpoints. await cart.add(sku, qty); return { text: `Added ${qty} × ${sku}.`, data: { count: cart.count, total: cart.total }, }; }, }); ``` ## Field reference | Field | Required | What it is for | |---|---|---| | `name` | yes | Lowercase words separated by single spaces. Start with the provider: `cart add`, `form set`. | | `provider` | yes | Who owns the command. `help ` lists one group. | | `title` | no | A short name for people, such as "Submit a form". WebMCP shows it as the tool's title, and the generic approval question uses it: "Submit a form? (form submit)". One line. | | `description` | yes | What it does, in one line first. `help` shows the first line, `man` all of it; agents read all of it. See [descriptions](../guides/descriptions.md). | | `input` | no | JSON Schema for the input, checked before `run()`. Leave it out for a command with no arguments. Name parameters with one lowercase word (`target`, `limit`), or words joined by `_` (`per_page`); `register()` warns about other names. | | `annotations` | yes | Hints such as `readOnly` and `consequential`. See [annotations](annotations-and-policy.md). | | `positional` | no | Input keys that can be given without `--name=`, in order. The last takes the rest of the line. | | `output` | no | JSON Schema for `data`. Drives tables and `man`, and tells agents what comes back. Data that does not fit logs a warning. | | `examples` | no | Command lines, shown by `man` and given to agents. | | `seeAlso` | no | Related command names. | | `confirm` | no | `(input) => "Place an order for 2 items?"`: the question when the policy asks. It gets the checked input (converted, defaults filled), the same value `run()` gets. If it throws, the generic question is asked. | | `suggest` | no | `(key, partial, { input, signal })` returns values for completion. May be async. | | `view` | no | `{ columns: [...] }`: the columns a table shows. `--wide` shows all. A table shows the listed columns it has; a table with none of them shows all its columns, so a command that returns different lists can name the columns of each. | | `run` | yes | `(input, { caller, signal })`. Return `{ text?, data? }`, or a string for text only; throw to fail. | Use one verb for one meaning across your commands: `search` for finding, `add` and `remove` for what goes into or out of something, `go` for going to another page, `status` for the state. Agents pick tools by name; two verbs for the same thing make them guess. Names starting with `help`, `man`, `status` or `clear` are refused: those words belong to clients. So are inputs named `help`, `json` or `wide`. ## How input is checked Sutram checks input against the JSON Schema keywords that apply to one value, before `run()` is called: - `type`, `required`, `enum`, `const`, `format`; - numbers: `minimum`, `maximum`, `exclusiveMinimum`, `exclusiveMaximum`, `multipleOf`; - strings: `minLength`, `maxLength` (in characters), `pattern`; - arrays: `items` (one level), `minItems`, `maxItems`, `uniqueItems`; - `type: ["string", "null"]` and the like, for a value that may be left out. Keywords that combine schemas (`oneOf`, `anyOf`, `allOf`, `if`, `$ref`, properties of a nested object, …) are not checked, and `register()` warns about them once. Check those in `run()`. Arguments the schema does not declare are refused. Text typed in the console is converted to the declared type: `"2"` becomes `2`, `yes` becomes `true`, `a,b` becomes a list. Values that arrive typed, from an agent or from code, are not converted: `true` for an integer is a usage error, not `1`. `null` and a missing value mean "not given": the `default` applies, or a `required` key is reported. An empty string is a value for a string property, so `--title=""` clears a title and counts as present for `required`, as in JSON Schema. If your command needs non-empty text, add `minLength: 1`, or check it in `run()`. For numbers, booleans and lists, an empty value (`--qty=`) means "not given". ## Return data, not text Return `data`, and optionally a short `text` for people. The console formats the data (tables, trees, `key: value`); agents get it as it is. See [Results](results.md). ## Register when it makes sense, remove when it does not `register()` returns a function that removes the command. A provider can register commands when its own state makes them possible, and remove them when it no longer does: a `checkout` command only while the cart has something in it. `help`, completion and the agent's tool list follow along. This is how an agent finds out what is possible now, without guessing. Content that other code loads and removes (a form in a dialog, a step loaded by AJAX, a rebuilt form) is different: register its commands once, with the content as an argument (`form set `), and look the content up when the command runs. A command that holds on to content can otherwise act on something that is no longer on the page. See [Forms](../guides/forms.md). ## A status command per provider Give each provider a read-only ` status` command that returns a one-line `text` and the state as `data`. A bare `status` runs all of them, and agents get `sutram_status`, so one word shows the state of the whole page. ## Providers A provider is the part of the app that owns some commands: the cart, a form, the menu, an editor. Sutram itself holds no app state and knows no domain. It has no idea what a cart or a node is; providers do. Several providers on one page share one registry, so their commands sit side by side in `help` and in the tool list. --- Source: https://d34dman.github.io/sutram/docs/guides/descriptions.md # Write descriptions agents can use Agents do not read your docs site. They read `help`, `man` and the tool list, and all three are generated from your command definitions. So the `description`, the schemas and the `examples` are the documentation that matters most. ## Names - Start with the provider, then a verb: `cart add`, `nav go`, `form set`. - Use the same verbs everywhere: `status`, `list`, `find`, `get`, `set`, `add`, `remove`, `save`, `discard`. - Lowercase words, single spaces. Hyphens are fine inside a word. ## The description One line, what the command does, from the caller's side: | Weak | Better | |---|---| | `Cart` | `Add a product to the cart` | | `Handles saving` | `Save the article form, the same as clicking Save. The page reloads` | | `Set field value` | `Change one field. Nothing is sent until form submit ` | Say what happens that the caller cannot see: that nothing is saved yet, that the page reloads, that new fields can appear. ## Input properties - Give every property a `type`. - Add a `description` when the name does not say enough: where the value comes from ("Product code, from shop list"), what format it takes, what a yes/no field accepts. - Prefer `enum` for a closed list. It checks input, completes with Tab, and tells agents the choices. - Set `default` when there is a sensible one, and `minimum`/`maximum` when there are limits. - For dynamic choices, add `suggest`. ## Output Add an `output` schema. Agents see "Returns { count, total }" in the tool description and know what to read. Give properties a `title` when the column heading should differ from the key. ## Examples Add `examples` for any command with more than one argument, or with a value that needs quoting. Write them as command lines: `man` shows them, and agents copy their shape. ```js examples: ["cart add V60-02", "cart add GUJI-250 2", "cart add SCALE --qty=1"], ``` ## Annotations They are part of the description too. A missing `readOnly: true` makes a harmless command look risky; a missing `consequential: true` lets an agent place an order without asking. Decide each one on purpose. ## Check your work ``` › help › man ``` Then read `Sutram.tools()` as if you had never seen the app. If you would not know what to call, neither will an agent. --- Source: https://d34dman.github.io/sutram/docs/guides/errors.md # Errors that help agents An agent reads your error message and decides what to do next. A good one leads to one corrected command. A vague one leads to guessing, or to starting over. ## What Sutram does for you | Mistake | Exit | Message comes from | |---|---|---| | Unknown command | 127 | Sutram, with "did you mean" when a name is close | | Unknown option or argument, wrong type, missing required input, a value outside the schema's limits | 2 | Sutram, from the input schema ([what is checked](../concepts/commands.md#how-input-is-checked)) | | Needs approval it did not get | 130 | Sutram, with the question to ask | | Anything your `run()` throws | 1, or the code of a `CommandError` | You | So the schema is your first error message. A `minimum`, an `enum`, a `maxLength`, a `pattern` or a `required` turns into a precise usage error without a line of code. It ends with where the usage is: `form get: field is required. Try form get -h.` ## Writing your own Throw a `CommandError` with a message and an exit code: ```js throw new CommandError( `${base} set: "${s}" is not a valid invoice number. ` + "Use INV- and at least four digits, such as INV-20417.", EXIT.USAGE, ); ``` A good message: 1. **Starts with the command**, so a failed line in a batch is obvious. 2. **Says what is wrong** with this input, not in general. 3. **Says what would work**: the format, the allowed values, an example. 4. **Names the next command** when there is one: "Set it first, for example: `form set request email …`". 5. **Lists everything at once.** "email, summary and phone are required" saves two round trips over naming one at a time. Use `EXIT.USAGE` (2) when the input was wrong, and `EXIT.FAILED` (1) when the input was fine but it did not work, including when the thing it names (a form, a list, a menu) is not on the page. `EXIT.NOT_FOUND` (127) belongs to the clients: it means the command itself does not exist. ## Hints on success, too Results guide agents as much as errors do: - "The cart is empty. Try shop list, then cart add." - "topic set. The form now also asks for: invoice, amount." - "Saving. The page reloads; then status shows where it went." ## Batches stop at the first failure A pasted block, a multi-line entry and `sutram_batch` stop at the first command that fails, and the console says which line. Earlier commands keep their effect unless the provider has [batch hooks](../concepts/batches.md). So the agent's correction is the failed line and the ones after it. --- Source: https://d34dman.github.io/sutram/docs/concepts/annotations-and-policy.md # Annotations and the policy Annotations describe a command. The policy decides who has to agree before it runs. Keep the two apart: a command says it is consequential; the page decides whether that means asking. ## Annotations | Annotation | Means | The console | Agents | |---|---|---|---| | *(none)* | An ordinary change | Runs it | Can call it, unless the policy asks first | | `readOnly: true` | Changes nothing | Runs it; marks it read-only | Get `readOnlyHint` | | `consequential: true` | Significant or hard to undo | Asks first (a pasted batch asks once) | Get `consequentialHint`; the page asks the user first | | `navigates: true` | Leaves the page. Implies `reinitializes` | Runs it; only as the last line of a pasted block | Told in the description that the page reloads | | `reinitializes: true` | May start Sutram again, such as a single-page app swapping its commands | Runs it; only as the last line of a pasted block | Told in the description; see below | | `untrustedContent: true` | Its output contains text from outside, such as user content | | Get `untrustedContentHint` | `annotations` is required, even when it is `{}`, so nobody forgets to think about it. Annotations are a contract with every client, so `register()` refuses a key it does not know, such as a typo. They are Sutram's own words: the WebMCP bridge translates them (`readOnly` becomes `readOnlyHint`) and never sends a name WebMCP has not defined. Word the question for a consequential command with `confirm`: ```js confirm: ({ qty }) => `Place an order for ${qty} items, €48.00?`, ``` The question may describe the page as it is, such as the values a form submit sends. Someone else can change the page while the user reads it: an agent sets a field while the person thinks about "Submit Contact with 1 change?". So `confirm()` runs again just before the command does, and when the question has changed, the command does not run (exit 130, "the page changed while the user was asked"); run it again to be asked about the page as it is now. Keep `confirm()` free of anything that changes on its own, such as the time, or every approved run is refused. ## Starting again Leaving the page throws away every command, event and answer on it, and a single-page app that swaps its commands does much the same. The annotation says a command *may* do this; the command says when it *does*, by calling `reinitializes()` from its run context just before: ```js register({ name: "nav go", annotations: { navigates: true }, run({ item }, { reinitializes }) { const url = find(item); if (!url) throw new CommandError(`No menu link "${item}".`); reinitializes(); setTimeout(() => location.assign(url), 0); return { text: `Going to ${url}.` }; }, … }); ``` A command that fails, or does not need to leave this time, does not call it, and nothing changes. A command that calls it without declaring `navigates` or `reinitializes` fails. From the call on: - `Sutram.ready` is a new promise. On a page being left it never resolves; the next page has its own. A single-page app resolves it with `Sutram.initialized()` once its new commands are registered. - Runs are refused, with exit 130 and "Sutram is starting again", so a call that comes too early never gets an answer from the old page. - Events from before are cleared. - An agent's result ends with a note: wait until Sutram is ready, then look again. App code outside a command, such as a single-page app's router, calls `registry.reinitialize()` itself, or `registry.reinitialize({ navigates: true })` before it leaves the page. Sometimes the page is not left after all: the person clicks "Stay on page", a router handles the URL in place, the URL only changes the `#hash`, the browser blocks a form submit, or the page comes back from the back/forward cache. When a command that declares `navigates` called `reinitializes()`, Sutram starts on the same page again when something says so. Runs work again and `Sutram.ready` resolves; nothing on the page is registered again. What says so: 1. The adapter's answer, or `Sutram.initialized()`. A kit's adapter returns `false` from `go()`, `submit()`, `login()` or `logout()`, or rejects, when the page stayed, and Sutram resumes at once. App code calls `Sutram.initialized()` for the same. 2. The browser. The browser entry point (`browser.js`, `dist/sutram.js`) resumes on a `hashchange` or on a `pageshow` from the back/forward cache, and, where the browser has the Navigation API, when a navigation ends in the document (`navigatesuccess`) or is stopped (`navigateerror`, as when the person clicks "Stay on page" in Chromium). Time alone never resumes it: a page that is still on its way out, such as one sending a slow form POST, must not answer an agent, which could send the form twice. So a navigation that ends without any of these signals leaves runs refused until the page loads again or the app calls `Sutram.initialized()`. The common case is a link that downloads a file: an adapter whose `go()` starts a download returns `false`. Browsers without the Navigation API (Firefox and Safari, at the time of writing) send no signal after "Stay on page" either. Step 2 applies only to `navigates`. After a `reinitializes` command, a single-page app calls `Sutram.initialized()` itself once its new commands are registered, or its adapter answers `false` when nothing changed. ## The policy There are two kinds of caller, a person at the console and an agent, and three levels: | Level | Asks before | |---|---| | `"none"` | nothing | | `"consequential"` (the default for both) | consequential commands | | `"changes"` | every command that is not read-only | ```js // An editor where an agent never changes the document unseen, // while people at the console are only asked about consequential commands: getRegistry().policy.set({ agent: "changes" }); ``` When an agent needs approval, the approval dialog shows the commands it wants to run and asks. A yes covers that call and nothing after it: with `"changes"`, every change asks, every time. The dialog cannot tell who clicks, so no answer may lower the level for the calls that follow. - To ask in your own dialog: `policy.setAsker(async ({ who, steps }) => true | false)`. Only `true` is yes. - Without `dist/sutram.js`, a console or an asker of your own, the browser's `confirm()` asks. - `Sutram.call()` and `Sutram.exec()` ask through the same dialog: the person at the page answers, never the caller. On a page with nobody to ask (no `dist/sutram.js`, console or asker), a call that needs approval returns exit 130 and does not run. A test sets an asker that answers for the person: `policy.setAsker(() => true)`. - App code that calls `registry.run()` is never asked. It is the app. - The console cannot tell who is typing. Lines typed there follow the person's level, and an agent that drives the browser can answer the console's questions itself. So can an agent that also drives the approval dialog or the browser's `confirm()`. Only a question the agent cannot reach keeps it from approving its own calls: WebMCP, when the agent does not also drive the page. The policy is not a security boundary. Any code in the page can call `registry.run()` directly, or click *Yes*. The policy makes well-behaved agents ask; it cannot stop code that already runs in the page, which could click the buttons just as well. ## Why the page decides WebMCP decides which sites' tools an agent may see. It does not decide whether a call may change the user's page. So every agent call goes through the page's policy first, whoever the agent is. ## Security Commands run in the browser with the user's own session. They can do exactly what the user could already do, and nothing more: Sutram adds no new way into your server. Access checks stay where they are, on the server. --- Source: https://d34dman.github.io/sutram/docs/guides/testing.md # Test with Playwright Code that can run JavaScript in the page gets the same tools as a WebMCP agent from `window.Sutram`, with no console and no WebMCP. That covers Playwright, Puppeteer, raw Chrome DevTools Protocol, and agents that drive a browser. ```js // Playwright; Puppeteer and CDP work the same way. const tools = await page.evaluate(() => Sutram.tools()); // → [ // { name: "sutram_status", … }, // { name: "cart_add", description, inputSchema, annotations }, // … // ] await page.evaluate(() => Sutram.call("cart_add", { sku: "V60-02", qty: 2 }), ); // → { code: 0, text: "Added 2 × V60 dripper…", data: { count: 2, total: 48 } } // checkout is consequential: the page's approval dialog asks, and the // call waits. The test answers as the person would. const checkout = page.evaluate(() => Sutram.call("checkout")); await page.locator("sutram-approval").getByRole("button", { name: "Yes" }).click(); await checkout; // → { code: 0, … }; No gives { code: 130, message: "The user declined checkout, so nothing ran." } ``` - `tools()` is the list a WebMCP agent gets. - `call()` takes the tool name (underscores) and follows the page's policy like a careful host: a call that needs approval asks the person in the page's approval dialog (`dist/sutram.js` and every console set it up), or in the app's own asker, and waits. A page with nobody to ask runs nothing that needs approval. A test that does not care about the question sets an asker that says yes: `Sutram.registry.policy.setAsker(() => true)`. - Strings are converted to the declared types, as in the console, so `call(name, Sutram.client.parse(line).input)` works. WebMCP calls are checked strictly. - Results and exit codes are the same as everywhere else. ## Across pages A command that leaves the page, or otherwise starts Sutram again, takes every command, event and answer of the page with it: Sutram keeps nothing from before. Its result says so. Wait for `Sutram.ready`, then look again. Remember yourself what you need from before, and compare. Until then, calls on the old page are refused (exit 130), never answered from it. With the user commands of the Drupal module's user demo: ```js const before = await page.evaluate(() => Sutram.call("user_status")); await page.evaluate(() => Sutram.call("user_login")); await page.waitForURL("**/user/login**"); // … the person logs in; the page comes back … await page.waitForFunction(() => window.Sutram); await page.evaluate(() => Sutram.ready); const after = await page.evaluate(() => Sutram.call("user_status")); // Compare before.data.name with after.data.name yourself. ``` `Sutram.ready` resolves once the page's scripts have run: providers that register when their script loads are there by then. See [starting again](../concepts/annotations-and-policy.md#starting-again). ## A test ```js import { test, expect } from "@playwright/test"; test("an agent can fill the cart and check out", async ({ page }) => { await page.goto("/"); const add = await page.evaluate(() => Sutram.call("cart_add", { sku: "V60-02", qty: 2 }), ); expect(add.code).toBe(0); expect(add.data.count).toBe(2); const tools = await page.evaluate(() => Sutram.tools().map((t) => t.name)); expect(tools).toContain("checkout"); const refused = await page.evaluate(() => Sutram.call("checkout")); expect(refused.code).toBe(130); }); ``` This tests the commands an agent will use, not the pixels. The same calls make good smoke tests for the app itself. ## Several commands in one call `Sutram.exec()` takes command lines, the way a person pastes them into the console, and answers with what the console would print. One `page.evaluate()` does a whole task, and the answer is usually shorter than the results as JSON: ```js const { code, text } = await page.evaluate(() => Sutram.exec(` form set contact name "Ada Lovelace" form set contact topic billing form status contact `)); // text: // › form set contact name "Ada Lovelace" // name: (empty) → "Ada Lovelace". Not sent yet. // › form set contact topic billing // … ``` - Several lines run as one [batch](../concepts/batches.md): every line is checked first, so a typo runs nothing; the batch stops at the first line that fails, and providers that support it undo the lines before. - `help`, `man`, `status`, `-h` and `--json` work as in the console. Add `--json` or `--json=a,b` to a line when you need its data. - It follows the page's policy like `call()`: the person is asked once for the lines; with nobody to ask, nothing that needs approval runs. - A line that leaves the page or starts Sutram again must be the last one. - The result is `{ code, text, completed, total }`. `exec` is also exported as `execLines(text, { registry })` from `@d34dman/sutram/webmcp`. ## Command lines in tests To test what a person types, parse a line and run it: ```js await page.evaluate(async () => { const { registry, client } = Sutram; const p = client.parse(registry, 'cart add V60-02 --qty=2'); return registry.run(p.def.name, p.input); }); ``` `registry.run()` is the app's own way in: it is never asked for approval. ## Unit tests without a browser The core has no DOM. In Node, create a registry and call it: ```js import { createRegistry } from "@d34dman/sutram"; const registry = createRegistry(); registry.register(/* … */); const result = await registry.run("cart add", { sku: "V60-02" }); ``` --- Source: https://d34dman.github.io/sutram/docs/guides/webmcp.md # WebMCP [WebMCP](https://github.com/webmachinelearning/webmcp) lets a page offer tools to an agent in the browser. Sutram sits on top of it: WebMCP is how the tools reach the agent, and Sutram decides what they are. Every part of the app registers its commands in one registry, and the same commands work in the console for people and through `Sutram.call()` where WebMCP is not there yet. See [Why not just WebMCP?](../why.md#why-not-just-webmcp). `connectWebMCP()`, or loading `dist/sutram.js`, registers every command as a tool in browsers that support it, and keeps the list in step with the registry. ```js import { connectWebMCP, findModelContext } from "@d34dman/sutram/webmcp"; connectWebMCP(registry, findModelContext()); ``` ## What agents get - **One tool per command.** The name is the command name with underscores: `cart add` becomes `cart_add`. - **The input schema**, and the annotations as hints: `readOnlyHint`, `consequentialHint`, `untrustedContentHint`. - **A description** that says what the command does, what it returns (from the output schema), whether it leaves the page, and what any app-defined formats mean. - **`sutram_status`**, which runs every ` status` at once and returns `{ code: 0, data: { providers: [{ provider, code, text?, data? }] } }`. Agents should call it first. Later, `{ providers: ["form", "page"] }` asks only those, so the agent does not read the whole page again. - **`sutram_batch`**, which runs several tools as one change: `{ calls: [{ tool: "cart_add", input: { sku: "V60" } }, …] }`. With `quiet: true` it leaves out the results of the calls that worked and returns `{ code, completed, total }`, plus the failing call's result as `failed`. It always has `untrustedContentHint`, as `sutram_status` does: it returns its calls' results as they are, page text included. When the page asks the person about a batch, the question includes each consequential command's own question, such as an order's price. When commands are added or removed, the tools follow. If you change a definition in place, register it again (`registry.register(def)`) so the tool's hints follow too; otherwise the tool is refreshed at its next call. A tool the browser refuses is skipped with a warning; the others still register. An agent's input is checked strictly: its JSON arrives typed, so `"off"` for a boolean or `"2"` for an integer is a usage error, not converted as text typed in the console would be. ## Approval WebMCP decides which sites' tools an agent may see, not whether a call may change the user's page. So every call goes through the page's [policy](../concepts/annotations-and-policy.md) first. When a call needs approval, the approval dialog (``) shows what the agent wants to run and asks the person. A page can set its own asker instead; with neither, the browser's `confirm()` asks. That includes `sutram_status`: its commands are read-only, but a page may mark one consequential. When the agent's host cancels a call while the person is deciding, the question is withdrawn and nothing runs. ## Without WebMCP Browsers without WebMCP still have every tool: `Sutram.tools()` and `Sutram.call()` for agents that run JavaScript in the page (see [Test with Playwright](testing.md)), and the console for agents that type. [`agentPrompt()`](agent-prompt.md) hands a task to any of them. --- Source: https://d34dman.github.io/sutram/docs/guides/console.md # The console `mountConsole()` adds a terminal-like console to the page. People type into it, and so may agents that drive a browser. It also shows each command an agent runs through a tool (WebMCP, `Sutram.exec()`, `Sutram.call()`) and its result, and the requests the person declined, so the person can see what agents do. It does not ask about agents' calls: the [approval dialog](#the-approval-dialog) does, so the console can stay closed, or be left out. What the console prints is also written, as text, to a visually hidden `role="log"` element next to it in the page (the last 200 lines). Screen readers announce new lines from there, and agents that read the page as text (`read_page`, `find`, `innerText`) see the output without looking into the console's shadow root. The console cannot tell who is typing. Everything typed there follows the policy for people, and an agent that drives the browser can answer the console's questions itself, by typing `y` or clicking *Yes*. The [agent prompt](agent-prompt.md) tells it not to, but that is a request, not a lock. Only a question the agent cannot reach keeps it from approving its own calls: the page's WebMCP tools, when the agent does not also drive the page, so the [policy](../concepts/annotations-and-policy.md) asks the person in the approval dialog. `Sutram.call()` and `Sutram.exec()` ask there too. Open it with the key left of 1 (outside text fields), or with Ctrl and that key anywhere; it opens with the cursor in the command field. Close it with Esc while it has the focus; an Esc meant for the page leaves it open. The key is found by its position, as game consoles do, so it is ` on a US keyboard, ^ on a German one and ² on a French one. Ctrl+` is also VS Code's terminal toggle. The drop-down console is a non-modal dialog: the page stays usable while it is open, and Tab on an empty command line moves on into the page. It gives focus back to where it was when it closes. As in a terminal, a click anywhere in the console puts the cursor in the command field, and the output has one scroll bar for all of it. Dragging or double-clicking selects text to copy instead; typing after that goes to the command field. | Type | To | |---|---| | `help`, `help ` | See the commands on this page | | `man ` | Read a command's full manual | | ` -h` | See a command's usage | | `status` | See every provider's status; `status form` for one | | `clear` | Clear the screen | | Tab | Complete a command, an option or a value; with several choices, a list opens | | ↑ / ↓ | Walk through history. It lasts as long as the page, unless the app passes a `historyStore` | | `y`, `n` (or `a`) while a question is open | Answer it. Anything else asks again; a second question waits its turn | | Enter inside quotes or a [heredoc](../reference/command-line.md#heredocs) | Start a new line of the value, for multi-line text | | Shift+Enter | Start a new line; several lines run as one batch | | Paste several lines | Run them as one [batch](../concepts/batches.md). Pasted inside open quotes or a heredoc, they become part of the value instead. | | *Copy* (top bar) | Copy the output as text | | *Agent prompt* (top bar) | Copy a [prompt](agent-prompt.md) that hands this page to an agent with a browser, with the page's title and URL filled in | ## Options `mountConsole({ greeting, locale, hotkey, peek, messages, historyStore })`: - `greeting`: a first line. - `locale`: the locale values are shown in. - `hotkey`: space-separated bindings, default `"backquote ctrl+backquote"`. A binding is modifiers (`ctrl`, `alt`, `shift`, `meta`, or `mod`: Cmd on macOS, Ctrl elsewhere) and a key. Letters and digits are the character the keyboard types, so `mod+k` stays K on any layout; other keys are named by position, as `KeyboardEvent.code` in lowercase: `backquote`, `slash`, `f2`. A binding without `ctrl`, `alt` or `meta` never fires in a text field. `"none"` turns the hotkey off; open the console from your own button with the element `mountConsole()` returns: `const el = mountConsole({ hotkey: "none" }); el.open()`. - `peek`: hold a key alone to see the page behind the console; see below. - `historyStore`: where history is kept between pages; see below. ### Keeping history between pages Sutram stores nothing in the browser: no cookies, no `localStorage`, no `sessionStorage`. Without a store, history lasts as long as the page. To keep it longer, pass a store with `load()` and `save(lines)`. Your app decides where the lines go, for how long, and under which key. Typed commands can hold content, such as text for a form, so choose the place with that in mind. ```js const KEY = "myapp.console-history"; mountConsole({ historyStore: { load: () => JSON.parse(sessionStorage.getItem(KEY) || "[]"), save: (lines) => sessionStorage.setItem(KEY, JSON.stringify(lines)), }, }); ``` `load()` may return a promise, for history kept on your server. The console keeps the last 100 lines and ignores a store that throws. If your app has its own command palette on Cmd/Ctrl+K, leave that key to it. Sutram's default avoids the keys browsers, Drupal's Coffee module (Alt+D) and Firefox's Web Console (Ctrl+Shift+K) use. ### Peeking at the page The console covers part of the page. With `peek`, holding a key on its own fades the console so you can see what happens behind it; let go and it is back. Off by default. ```js mountConsole({ peek: true }); // hold Alt (Option on a Mac) mountConsole({ peek: "ctrl" }); // or "shift", "meta" ``` The console fades after the key has been held for a moment, so Alt+letter shortcuts and Option characters do not flicker. Any other key, a click, or switching to another window ends the peek. While it peeks, the element has the `peeking` attribute; set how faded it is with `--sutram-peek-opacity` (default `0.12`). ## In your own panel Give `mountConsole()` a container, and it adds an inline console there instead of the drop-down. It fills the container, has no hotkey and no close button, and the app decides when to show it. ```js const panel = document.querySelector("#editor-console"); const el = mountConsole({ greeting: "Type help." }, panel); ``` ## Styling The console is dark by default. Sutram ships two optional skins; load one after Sutram: - `skins/light.css`: light. - `skins/system.css`: dark or light, following the system setting. ```html ``` With a bundler: `import "@d34dman/sutram/skins/light.css";`. A skin is nothing but values for the console's CSS custom properties, set on `sutram-console`. Write your own the same way, for example to match your app's theme. These names are a stable contract: a rename is a breaking change. ```css sutram-console { /* Colours */ --sutram-bg: #11131c; --sutram-fg: #e6e8f0; --sutram-dim: #8c93a8; --sutram-accent: #9aa2ff; --sutram-option: #7dcfff; --sutram-string: #c3e88d; --sutram-ok: #6fd69a; --sutram-warn: #f5a35c; --sutram-err: #ff7b7b; --sutram-on-warn: #11131c; /* text on the warn-coloured Yes button */ --sutram-line: #ffffff1a; /* borders, selected suggestion */ /* Shape */ --sutram-radius: 0; --sutram-shadow: 0 8px 32px #0007; /* Type and size */ --sutram-font: ui-monospace, monospace; --sutram-font-size: 13px; --sutram-height: 55vh; /* How visible the console stays while peeking */ --sutram-peek-opacity: 0.12; } ``` A translucent `--sutram-bg` lets the page show through all the time; add `sutram-console::part(panel) { backdrop-filter: blur(8px); }` to keep the text readable. Set `--sutram-on-warn` too, or the Yes button's text turns translucent with it. Where the drop-down sits is CSS on the element as well: `sutram-console { inset: auto 16px 16px auto; width: 560px; --sutram-radius: 8px; }` turns it into a box in the bottom right corner. Parts can be styled with `::part()`: `panel`, `header`, `output`, `menu`, `prompt`, and the top bar's buttons `agent-prompt`, `copy` and `close`. `sutram-console::part(header) { display: none; }` hides the top bar; `sutram-console::part(agent-prompt) { display: none; }` only one button. ## The approval dialog When an agent calls a command through WebMCP that needs the person's approval (see the [policy](../concepts/annotations-and-policy.md)), `` asks: a modal dialog with the commands and the question. *No* has the focus and Esc answers no, so a stray key never approves anything. When the browser closes the dialog itself (Chromium does after repeated Esc, to let people out of a run of dialogs), the open question and those waiting are answered no. For a batch, the question includes each consequential command's own question, such as an order's price. `dist/sutram.js` and every console add it as the policy's asker; a page that calls `policy.setAsker()` uses its own. It is styled with the console's `--sutram-*` variables and the parts `dialog`, `lead`, `lines`, `question`, `yes` and `no`, and translated with `APPROVAL_MESSAGES` from `@d34dman/sutram/approval`: ```js document.querySelector("sutram-approval").messages = { yes: "Ja", no: "Nein" }; ``` It fires `sutram-ask` and `sutram-answer` (bubbling, composed) with the steps and the question or the answer. ## In other languages Every text the console shows of its own is in `MESSAGES`. Assign the ones you translate; texts that take values are functions: ```js el.messages = { placeholder: "Befehl eingeben oder help", count: (n) => `${n} Befehle auf dieser Seite`, yes: "Ja", no: "Nein", }; ``` What commands print is theirs to translate. `help` and `man` are still in English. --- Source: https://d34dman.github.io/sutram/docs/guides/agent-prompt.md # Hand a task to a browser agent Sutram's commands run in the page, so an agent needs a browser it can drive: Claude Code or Codex with Playwright MCP, Claude Desktop, Claude in Chrome, or a browser with WebMCP. Fetching the URL is not enough. A chat with no browser tool cannot use the page. `agentPrompt()` writes the prompt that hands such an agent a task. ## The prompt ```js const prompt = Sutram.agentPrompt("Buy two V60 drippers, then check out."); ``` The prompt tells the agent to: - open the page and use the first of these it has: the page's WebMCP tools, `Sutram.exec()` with command lines, or the console. Tools answer as text; the console has to be read off the screen, which costs an agent far more. The person sees the commands it runs in the console either way; - use the page's commands, which the prompt lists as `help` prints them, run `status` for the page's state, and ask (`man `, ` -h`) whenever it is unsure; - leave hard-to-undo commands to the person: the page asks them, or the answer tells the agent to ask first. In the browser, `Sutram.agentPrompt(task)` names the page with `document.title` and `location.href` and lists the page's commands. Pass `{ title, url }` as the second argument to name it yourself. `agentPrompt` from `@d34dman/sutram` has no DOM: pass `{ title, url, commands }`, where `commands` is the page's help text, or leave it out and the prompt tells the agent to start with `help`. ## Your own prompt A site can ship its own prompt. `Sutram.defineAgentPrompt()` takes a function of the task and the page, and gets the default prompt as `page.prompt`, to wrap or to replace: ```js Sutram.defineAgentPrompt((task, page) => `${page.prompt}\n\nHouse rules: never order more than 100 € without asking me.`); // or a prompt of its own Sutram.defineAgentPrompt((task, { url, commands }) => `Open ${url} and use its commands:\n${commands}\n\nTask: ${task}`); ``` The console's prompt button and `Sutram.agentPrompt()` both use it. It returns a function that brings back the default; a function that throws or returns no text falls back to the default too. ## From the console The console's top bar has an *Agent prompt* button. It copies the same prompt for the page it is on, title and URL filled in, with the task "Show me what this page can do." The person pastes it into their agent and changes the task. No code is needed; hide the button with `sutram-console::part(agent-prompt) { display: none; }`. ## Why the prompt lists the commands An evaluation on the Drupal demo (October 2026) found that agents given only "start with help" spent several calls learning the console before their first change, and that an agent typing into the console through a browser used more tokens than one that never heard of Sutram. The prompt now carries the page's `help` (every command with its usage, and how to write a line) and sends the agent to tools first. `man` and the errors cover the rest. ## Agents that already know Sutram An agent that works with Sutram pages often can load the "use a Sutram page" skill from the package's `skills/` folder instead. It covers the same ground in more depth, built from `DISCOVERY_RULES` in `src/prompt.js`. --- Source: https://d34dman.github.io/sutram/docs/concepts/discovery.md # Discovery: help, man, status An agent that cannot find out what a page offers falls back to screenshots and guessing. In Sutram, discovery is part of the contract, and all of it is generated from your command definitions, so it always matches the page. | Question | Ask | Answer | |---|---|---| | What can I do here? | `help`, `help ` | One line per command: usage, description, annotation | | How does this one work? | `man `, ` -h` | Arguments, output, annotations, examples, related commands | | What state is the page in? | `status` | Every provider's ` status`, at once; `status form page` for only some | | What values fit here? | Tab, a command's `suggest` | Completions for names, options and values | ``` › help cart cart add [qty] Add a product to the cart cart status Show what is in the cart [read-only] ``` ## Cheap first, details on demand `help` costs one line per command. `man` is read only for the command the agent is about to use. `--json=a,b` returns only the fields asked for. This is why discovering a page through `help` costs about a third of the full tool list on the Drupal demo (see [Measurements](../measurements.md)). ## Agents without the console - Through WebMCP or `Sutram.tools()`, an agent gets every command as a tool with its schema, plus `sutram_status`. Start with `sutram_status`. - [`agentPrompt()`](../guides/agent-prompt.md) sends an agent to the console and tells it to start with `help` and `status`. ## Look again when things change Commands come and go with the page's state. A command that runs and changes what is possible should say so in its result ("The form now also asks for: invoice, amount"). Agents are told to look again when they expect something new. The `help` output, the tool list and completion follow the registry without any work from you. ## The integration ladder How well a part of the page works with agents depends on how far up this ladder it goes. Each step is useful on its own. | Level | The provider… | People and agents get | |---|---|---| | 1 · Status | answers ` status` with its current state | Agents can read the page without screenshots | | 2 · Writable | gets and sets values through its own logic, so the UI stays in sync | Agents can fill it reliably | | 3 · Commands | adds its own actions, with annotations | `cart add`, `editor clear` | | 4 · Suggests | offers values for its arguments | Completion for people, real choices for agents | --- Source: https://d34dman.github.io/sutram/docs/guides/forms.md # Forms Forms are where commands pay off most. An agent sets one field at a time, sees what the form asks for next, and fixes the one value that was wrong. Read [Why commands](../why.md) for the argument; this guide is the how. ## The command set Use the same commands for every form, with the form as the first argument, so an agent that has filled one knows them all: | Command | Does | Annotations | |---|---|---| | `form list` | The forms on the page now; with one form, its fields too | read-only, `untrustedContent` | | `form status ` | Unsaved changes, missing fields, errors | read-only, `untrustedContent` | | `form fields ` | Every field the form has now: name, label, type, required, value | read-only, `untrustedContent` | | `form get ` | One field in full, with its allowed values or format | read-only, `untrustedContent` | | `form set ` | Change one field. Nothing is saved yet | `untrustedContent` | | `form submit ` | Send it, the same as the form's button | consequential | | `form discard ` | Undo the unsaved changes | consequential | The [forms kit](forms-kit.md) registers exactly these, with `form add`, `remove`, `search` and `upload` where your app has them; give it an adapter instead of writing them yourself. The rest of this guide is how they behave. Register these once, when your script loads, and look the form up each time a command runs. Forms come and go (a dialog opens, a step loads, a form is rebuilt), but the commands stay the same, so agents see one tool list for the whole page and no command can act on a form that is gone. Drupal's [form commands](../drupal.md) and the [forms example](https://d34dman.github.io/sutram/example/forms.html) both follow this. ## Set one field ```js registry.register({ name: "form set", provider: "form", description: "Change one field. Answers can add fields to the form: the result names them", input: { type: "object", properties: { form: { type: "string", description: "The form's id. form list shows them." }, field: { type: "string", description: "A field of the form now. form fields lists them." }, value: { type: "string", description: "The new value. Yes/no fields take true or false; " + "choices take a key or its label.", }, }, required: ["form", "field", "value"], }, positional: ["form", "field", "value"], annotations: {}, // Completion: the forms on the page, then the fields of the form typed. suggest: (key, partial, { input }) => (key === "form" ? formIds() : key === "field" ? fieldsOf(input.form) : []) .filter((v) => v.startsWith(partial)), run: ({ form, field, value }) => { // Find the form now, or fail with the forms that are open. Check the // field against the fields it has now. Convert the value, write it into // the real input, report what changed. }, }); ``` - `form` and `field` are checked when the command runs, against the page as it is then: an unknown one is a usage error that lists the valid names. `suggest` gives completion. - `value` is a string, converted per field by your code. That keeps one `set` for every field type, and lets your messages explain each type. - Write into the real input (and fire `input` and `change` events), so the form on screen and the commands always agree, and the app's own behaviour (conditional fields, summaries) runs as if a person typed. ## Progressive disclosure When an answer changes which fields exist, the commands need no change: they check fields when they run, and `form fields` and completion read the form as it is now. Say so in the result, so the agent does not have to guess: ``` › form set request topic billing topic set. The form now also asks for: invoice, amount. › form set request topic bug topic set. The form now also asks for: product, severity, steps. No longer asked: invoice, amount. ``` Return the lists in `data` too (`added`, `removed`), for agents that read JSON. ## Validation errors Check what you can in `set`, and say what a valid value looks like: ``` › form set request invoice 2024-17 form set: "2024-17" is not a valid invoice number. Use INV- and at least four digits, such as INV-20417. ``` Check required fields before sending, and name all of them at once, with an example of the next command: ``` › form submit request form submit: email, summary, product, phone are required. For example: form set request email … ``` Errors the server finds after `submit` belong in `form status `, by field. See [Drafts and saving](drafts-and-saving.md). ## What the agent does ```sh form list form fields request form set request topic billing form fields request form set request invoice INV-20417 form set request email ada@example.com form set request summary "Charged twice in May" form status request form submit request ``` Read, change one thing, read what changed, fix what failed, send. The fields it set stay set when one line fails, so a correction is one line. --- Source: https://d34dman.github.io/sutram/docs/guides/drafts-and-saving.md # Drafts and saving Many apps already keep a working copy of their data in the browser: a form, an editor, a canvas. Sutram commands act on that copy. Many changes cost no requests, and one save sends the result. It works like git. The working copy is the working tree, `status` shows unsaved work, and `save` is the commit. ## The convention A provider that holds a draft: - reports unsaved work in ` status`: a list of what changed in `data.unsaved`, empty when there is nothing to save; - registers ` save` and ` discard`. Whether `save` is consequential is the provider's call. Publishing probably is; saving a draft that nobody else sees probably is not. `discard` throws work away, so it usually is. ## Why save once Compared with a server call per change: - one request, one save, one revision with one intended change; - validation, access checks and hooks run once; - the agent's changes are local calls, not round trips; - the person can look at the result before it is sent. ## What save must report Server validation and access checks run only at the save, so: - **Field errors**, by field, in ` status` after the save comes back. Drupal's form commands do this. - **Conflicts**, plainly: Drupal refuses to save a node that changed since the form loaded. Say that, and what to do. - **Where it went**: if saving leaves the page (`navigates`), call `reinitializes()` before it does, and say in the result where it went, so the agent knows what to read with `status` once the next page is ready. ## Keeping the draft An unsaved draft lives only in the tab. If keeping it across reloads matters, that is the app's job, not Sutram's. --- Source: https://d34dman.github.io/sutram/docs/concepts/batches.md # Batches A pasted block, a multi-line entry in the console and an agent's `sutram_batch` call each run as one batch: - Every line is checked before any runs, so a batch with a typo runs nothing. - The user is asked at most once. - The batch stops at the first command that fails. ## One change, one undo step A provider can make a batch one change with batch hooks: ```js registry.defineBatch("editor", { // Before the provider's first command in the batch. begin: () => history.startTransaction(), // Every command succeeded. commit: () => history.commitTransaction(), // One failed: undo the provider's earlier commands. rollback: () => history.cancelTransaction(), }); ``` Providers without hooks keep what their earlier commands did, and the console says so. That is often right: the form fields an agent already set stay set, and it only has to fix the line that failed. ## From code ```js await registry.batch([ { name: "cart add", input: { sku: "V60-02", qty: 2 } }, { name: "cart add", input: { sku: "FILTER-02" } }, ]); // → { code, results, completed, total, message?, rolledBack?, kept? } ``` Or run commands one at a time and decide at the end: ```js const b = registry.startBatch(); await b.run("cart add", { sku: "V60-02" }); await b.run("cart add", { sku: "FILTER-02" }); await b.commit(); // or b.rollback() ``` Always finish a started batch with `commit()` or `rollback()`: batches run one at a time, and the next one waits until you do. ## One at a time, and what was checked - **Batches take turns.** A second batch waits until the first has committed or rolled back. A person's or an agent's command for a provider the running batch is changing waits too, so a rollback cannot undo their edit. App code calling `registry.run()` does not wait: it may be running inside one of the batch's own commands. - **Each step runs the command that was checked.** If an earlier step replaces a later step's command (a command that comes and goes with page state), that step does not run (exit 130), and the batch rolls back. Clients pass the definition they asked the user about, so a command swapped in while the user was deciding does not run either: `registry.run(name, input, { expect: def })`, or `def` on a batch step. ## For agents Through WebMCP, agents get a `sutram_batch` tool: `{ calls: [{ tool: "cart_add", input: { sku: "V60" } }, …] }`. Use it for edits that belong together; it saves round trips and asks the user once. Add `quiet: true` when the results of the calls that work do not matter: the answer is then `{ code, completed, total }`, with the result of the call that failed as `failed`. --- Source: https://d34dman.github.io/sutram/docs/concepts/results.md # Results and exit codes Every run resolves to a result shaped like a process exit, whoever called it: the console, an agent, a test or the app. | Code | Means | Shape | |---|---|---| | `0` | Done | `{ code: 0, text?, data? }` | | `1` | Ran, but failed | `{ code: 1, message }`. Throw an `Error`, or a `CommandError` with your own message. | | `2` | Usage error: the input did not fit the schema | `{ code: 2, message }` | | `127` | No such command | `{ code: 127, message }`, with "did you mean" when something is close | | `130` | Cancelled, or not allowed to run without approval | `{ code: 130, message }` | ```js import { CommandError, EXIT } from "@d34dman/sutram"; throw new CommandError( "form submit: email is required. " + "For example: form set request email …", EXIT.FAILED, ); ``` Only a `CommandError` sets the code. Anything else thrown is exit 1, even when it has a numeric `code` of its own, such as a `DOMException`. See [Errors that help agents](../guides/errors.md) for how to word them. ## How results look You return data; the console decides how to show it: - a list of records becomes a table (pick columns with `view: { columns: [...] }`), - a record becomes `key: value` lines, - records with `children` become an indented tree, - `text`, when you return it, comes first. Agents always get `data` as it is. ## Options every command gets | Option | Prints | |---|---| | `--json` | The data unchanged, exactly as an agent gets it | | `--json=sku,price` | Only those fields: of the record, else of each row in its lists (`{ total, rows }` gives `{ rows: [{ sku, price }, …] }`) | | `--wide` | Every column, including those `view.columns` leaves out | | `-h`, `--help` | The command's usage | ## Output schemas An `output` schema is optional but worth it. It drives table columns and `man`, tells agents what comes back (WebMCP tool descriptions include it), and results are checked against it: data that does not fit logs a warning in the browser console, so a provider that drifts from its schema is noticed. `createRegistry({ checkResults: false })` turns the check off. --- Source: https://d34dman.github.io/sutram/docs/concepts/formats.md # Formats `format` in a schema works as in JSON Schema, where apps define their own formats. Sutram defines no domain formats. The app that owns the data defines them, and the console uses them to check input and show values: ```js import { getFormats } from "@d34dman/sutram"; getFormats().define("money", { type: "number", // For man and agents. description: "an amount in euros", // Bad input is a usage error. validate: (v) => Number.isFinite(v), display: (v, { locale }) => new Intl.NumberFormat(locale, { style: "currency", currency: "EUR" }) .format(v), // In tables. align: "right", }); // Then, in an output schema: // total: { type: "number", format: "money" } ``` - A format nobody defined is left alone. - A name can be defined only once per page, so two modules cannot disagree about what `money` means. - `date-time` and `date` are defined out of the box: checked as RFC 3339 (`2026-10-15`, `2026-10-15T14:30:00Z`; a date must be on the calendar) and shown in the console's locale. A value that is not a date shows as written. - Agents read the format's `description` in `man` and in tool descriptions. --- Source: https://d34dman.github.io/sutram/docs/concepts/events.md # Events Commands are for intent: "please do this". Events are for facts: "this happened". They are optional. ```js import { getEvents } from "@d34dman/sutram/events"; const events = getEvents(); events.define("cart.changed", { description: "The cart changed", payload: { type: "object", properties: { count: { type: "integer" } }, required: ["count"], }, }); events.emit("cart.changed", { count: 2 }, { source: "agent" }); events.on("cart.changed", (e) => render(), { as: "cart badge" }); ``` Only declared events can be emitted, and payloads are checked against their schema. ## For agents: wait, or poll Agents cannot be notified, so they ask. Declaring events adds three commands: - `events list`: the events this page declares. - `events recent --since=`: the events after an id. - `events wait --since= --timeout=`: returns the first matching event after the id, at once when the page already keeps one, otherwise as soon as it is emitted. Without a name it takes any event; without `--since` it waits for the next one. It gives up after 10 seconds by default, 60 at most, and then fails (exit 1) with the line that keeps waiting without missing one. `events wait` saves an agent the loop of asking `events recent` again and again after something that finishes later: a field the form loads, a list that reloads, a message after a save. ``` form submit node-article-form events wait message.shown --since=12 ``` In a [batch](batches.md), a wait that times out fails the batch like any other command. In JavaScript, `events.next(name, { since, timeout, signal })` does the same and resolves to the event, or to `null` (the timeout is in milliseconds there): ```js const event = await events.next("cart.changed", { timeout: 5000 }); ``` The page keeps the last 50, and forgets older ones when Sutram starts again (a command calls `reinitializes()`, or the app calls `registry.reinitialize()`; see [Annotations and the policy](annotations-and-policy.md#starting-again)). --- Source: https://d34dman.github.io/sutram/docs/reference/api.md # API Version 0.9.0. This page is generated from the TypeScript declarations in `types/`, which a test keeps in step with `src/`. For what the pieces are for, start with [Commands and providers](../concepts/commands.md). ## `@d34dman/sutram` What app authors use: the registry, formats, the policy, commandLine() and agentPrompt(), and the types every entry point shares. No DOM. ```ts export type SchemaType = | "object" | "array" | "string" | "number" | "integer" | "boolean" | "null"; /** The subset of JSON Schema that command input and output use. */ export interface Schema { type?: SchemaType | [SchemaType, "null"] | ["null", SchemaType]; title?: string; description?: string; properties?: Record; required?: string[]; items?: Schema; enum?: unknown[]; examples?: unknown[]; default?: unknown; format?: string; minimum?: number; maximum?: number; exclusiveMinimum?: number; exclusiveMaximum?: number; multipleOf?: number; minLength?: number; maxLength?: number; pattern?: string; const?: unknown; minItems?: number; maxItems?: number; uniqueItems?: boolean; $ref?: string; $defs?: Record; [key: string]: unknown; } export declare const EXIT: Readonly<{ OK: 0; FAILED: 1; USAGE: 2; NOT_FOUND: 127; CANCELLED: 130; }>; /** 0 done, 1 failed, 2 usage error, 127 not found, 130 cancelled, or a CommandError's own code. */ export type ExitCode = 0 | 1 | 2 | 127 | 130 | (number & {}); /** Thrown from run(), sets the result's code. 0 counts as 1: a thrown error is never success. */ export declare class CommandError extends Error { constructor(message: string, code?: ExitCode); code: ExitCode; } /** A contract with every client. register() refuses keys not in ANNOTATIONS. */ export interface Annotations { readOnly?: boolean; consequential?: boolean; /** Leaves the page. Implies reinitializes. */ navigates?: boolean; /** May start Sutram again: run calls context.reinitializes() when it does. */ reinitializes?: boolean; untrustedContent?: boolean; } /** The annotation keys register() accepts. */ export declare const ANNOTATIONS: readonly ["readOnly", "consequential", "navigates", "reinitializes", "untrustedContent"]; /** Who ran a command: "console", "agent", "code", "status", or a client's own name. */ export type Caller = "console" | "agent" | "code" | "status" | (string & {}); export interface RunContext { caller: Caller; signal?: AbortSignal; /** * Says Sutram starts again now, as the command is about to leave the page or * swap the page's commands. Throws unless the command declares reinitializes * or navigates. */ reinitializes(): void; } export interface CommandOutput { text?: string; data?: D; } /** A completion a command offers for one of its arguments. */ export type Suggestion = string | { value: string; label?: string; description?: string; }; export interface CommandDefinition = Record, D = unknown> { /** Lowercase words separated by single spaces: "cart add". */ name: string; /** Who owns it; help groups by this. */ provider: string; /** A short name for people, such as "Save a form": tool title in WebMCP, and the question before a consequential run. */ title?: string; description: string; /** JSON Schema for the input. Name parameters with one lowercase word, or words joined by _ (per_page). Leave it out for a command with no arguments. */ input?: Schema; output?: Schema; /** Input keys that can be given without --, in order. The last takes the rest of the line. */ positional?: string[]; annotations: Annotations; /** The question asked before the command runs, when the page's policy asks. Gets the checked input, as run() does. It is asked again just before the run, and a different answer means the page changed meanwhile, so nothing runs: keep it free of things that change on their own, such as the time. */ confirm?: (input: I) => string; /** Values for an argument, for completion. May be async. */ suggest?: ( key: string, partial: string, context: { input: Partial; signal?: AbortSignal }, ) => Suggestion[] | Promise; view?: { columns?: string[] }; examples?: string[]; seeAlso?: string[]; /** Returns { text?, data? }, a string (the text), or nothing. */ run: ( input: I, context: RunContext, ) => CommandOutput | string | void | Promise | string | void>; } /** * A run's result. Each side names the other's fields as never, so `r.text` * and `r.message` type-check on any Result, and `"message" in r` narrows. */ export type Result = | { code: 0; text?: string; data?: D; message?: never } | { code: Exclude; message: string; text?: never; data?: never }; export interface BatchHooks { begin?: () => unknown; commit?: () => unknown; rollback?: () => unknown; } export interface BatchEnd { /** Providers whose changes were undone. */ rolledBack: string[]; /** Providers whose changes stay because they have no batch hooks. */ kept: string[]; } /** * `expect`: the definition the caller checked or asked about. When the name maps to another by the time it runs, nothing runs (130). * `asked`: the question the user said yes to, from the command's confirm() (null when it has none). When it would ask something else by the time it runs, because the page changed meanwhile, nothing runs (130). */ export interface RunOptions { caller?: Caller; signal?: AbortSignal; expect?: CommandDefinition; asked?: string | null; } /** A started batch. Always finish it with commit() or rollback(): the next batch waits until you do. */ export interface Batch { run( name: string, input?: Record, options?: { expect?: CommandDefinition; asked?: string | null }, ): Promise; commit(): Promise; rollback(): Promise; } export interface BatchResult { code: ExitCode; message?: string; results: Result[]; completed: number; total: number; rolledBack?: string[]; kept?: string[]; /** For an agent: the batch started Sutram again (see REINITIALIZED_NOTE). */ note?: string; } export type ChangeEvent = { type: "register" | "unregister"; name: string; def: CommandDefinition; }; export type RunEvent = { name: string; input: Record; caller: Caller; }; export type ResultEvent = RunEvent & { result: Result }; export interface Registry { readonly formats: Formats; readonly policy: Policy; register, D>(def: CommandDefinition): () => void; get(name: string): CommandDefinition | undefined; has(name: string): boolean; list(): CommandDefinition[]; onChange(fn: (event: ChangeEvent) => void): () => void; onRun(fn: (event: RunEvent) => void): () => void; onResult(fn: (event: ResultEvent) => void): () => void; /** Resolves once the page's commands are registered; a new promise after every reinitialize(). */ readonly ready: Promise; /** How many times Sutram started again on this page. */ readonly generation: number; /** * Sutram starts again: clears events, refuses runs, and makes ready wait for initialized(). * navigates: true says the page is being left; if it is not left after all, the browser * entry point calls initialized() again. A command's reinitializes() passes its navigates annotation. */ reinitialize(options?: { navigates?: boolean }): void; /** The page's commands are registered: resolves ready and allows runs again. */ initialized(): void; /** Called on every reinitialize(). navigates is true when the page is being left. */ onReinitialize(fn: (event: { generation: number; navigates: boolean; }) => void): () => void; /** A console or agent run of a provider a batch is changing waits until the batch is done. */ run( name: string, input?: Record, options?: RunOptions, ): Promise; defineBatch(provider: string, hooks: BatchHooks): () => void; startBatch(options?: { caller?: Caller; signal?: AbortSignal }): Batch; /** One batch runs at a time; the next waits. A step with `def` runs only while its name still maps to that definition. */ batch( steps: Array<{ name: string; input?: Record; def?: CommandDefinition; asked?: string | null; }>, options?: { caller?: Caller; signal?: AbortSignal }, ): Promise; } export declare function createRegistry(options?: { checkResults?: boolean; formats?: Formats; policy?: Policy; }): Registry; export declare function getRegistry(): Registry; ``` ### parse ```ts export interface Token { value: string; quoted: boolean } export interface Parsed { def: CommandDefinition; input: Record; help: boolean; json: boolean; wide: boolean; fields: string[] | null; } export type SpanKind = | "space" | "command" | "client" | "unknown" | "option" | "bad-option" | "value" | "string" | "arg"; export interface Completion { value: string; label: string; description: string } ``` ### policy ```ts export type Level = "none" | "consequential" | "changes"; export type Who = "person" | "agent"; /** `signal` aborts when the call is withdrawn: the question can go, and its answer no longer counts. */ export interface AskRequest { who: Who; caller: Caller; steps: Array<{ def: CommandDefinition; input: Record }>; signal?: AbortSignal; } /** The user's answer: true for yes. A yes covers the one call it was asked about. */ export type Answer = boolean; export interface Policy { get(): { person: Level; agent: Level }; set(levels: Partial<{ person: Level; agent: Level }>): void; needs(def: CommandDefinition, who: Who | null | undefined): boolean; setAsker(fn: (request: AskRequest) => Answer | Promise): () => void; hasAsker(): boolean; ask(request: AskRequest): Promise; } export declare const LEVELS: readonly Level[]; export declare function createPolicy(levels?: Partial<{ person: Level; agent: Level; }>): Policy; ``` ### formats ```ts export interface FormatDefinition { type?: string; description?: string; validate?: (value: unknown) => boolean; display?: (value: any, options: { locale?: string }) => string; align?: "left" | "right"; } export interface Formats { define(name: string, def: FormatDefinition): () => void; get(name: string): FormatDefinition | undefined; has(name: string): boolean; list(): Array<{ name: string } & FormatDefinition>; valid(name: string, value: unknown): boolean; } export declare const STANDARD_FORMATS: readonly string[]; export declare function createFormats(options?: { standard?: boolean }): Formats; export declare function getFormats(): Formats; ``` ### format ```ts export interface Block { kind: string; text: string; head?: string; code?: ExitCode; commands?: CommandDefinition[]; } export interface FormatOptions { locale?: string; formats?: Formats; json?: boolean; wide?: boolean; fields?: string[] | null; } /** Input as the command line a person would type. The line parses back to the same input. */ export declare function commandLine( def: CommandDefinition, input?: Record, ): string; /** * The default prompt that hands a task to an agent that drives a browser. * It sends the agent to the page's WebMCP tools, Sutram.exec() or the * console, in that order. `commands` is the page's help text; without it, * the agent starts with help. */ export declare function agentPrompt( task?: string, page?: { title?: string; url?: string; commands?: string }, ): string; /** What a site's prompt function gets: the page, and the default prompt to wrap or replace. */ export interface AgentPromptPage { title?: string; url?: string; commands: string; prompt: string; } /** * Ships a site's own agent prompt: the console's prompt button and * Sutram.agentPrompt() use it. Returns a function that brings back the * default. A function that throws or returns no text falls back to it. */ export declare function defineAgentPrompt( make: (task: string, page: AgentPromptPage) => string, options?: { registry?: Registry }, ): () => void; ``` ## `@d34dman/sutram/client` For building a client of your own (a terminal, a console, a test harness): parse and complete command lines, check input, run status, format results. Covered by semver. No DOM. ```ts // Declarations for @d34dman/sutram/client: what a client of the registry // needs. Covered by semver. export declare const RESERVED_WORDS: readonly ["help", "man", "status", "clear"]; export declare const CLIENT_OPTIONS: readonly ["help", "json", "wide"]; /** With { strict: true }, strings are not converted to the declared types (for values from code, such as event payloads). */ export declare function checkInput( schema: Schema, input?: Record | null, formats?: Formats, options?: { strict?: boolean }, ): { errors: string[]; value: Record }; export declare function checkOutput( schema: Schema, data: unknown, root?: Schema, formats?: Formats, ): string | null; export declare function isStatusCommand(def: CommandDefinition): boolean; /** Runs every status command, or only those of `providers`. `run` runs each (registry.run by default); a batch passes its own run. */ export declare function runStatus( registry: Registry, options?: { caller?: Caller; signal?: AbortSignal; providers?: string[]; run?: ( name: string, input: Record, options?: { caller?: Caller; signal?: AbortSignal }, ) => Promise }, ): Promise>; export declare function tokenize(line: string): Token[]; export declare function parse(registry: Registry, line: string): Parsed; export declare function suggest(registry: Registry, words: string[]): string | null; export declare function highlight( registry: Registry, line: string, ): Array<{ text: string; kind: SpanKind }>; export declare function complete( registry: Registry, line: string, options?: { signal?: AbortSignal; limit?: number }, ): Promise<{ start: number; end: number; items: Completion[] }>; export declare function splitLines(text: string): string[]; /** What the text leaves open at its end: a quote, `<, ): string | null; export declare function formatResult( result: Result, def?: Partial, options?: FormatOptions, ): Block[]; export declare function blocksToText(blocks: Block[]): string; export declare function formatHelp(registry: Registry, prefix?: string): Block[]; export declare function formatUsage(def: CommandDefinition): Block[]; export declare function formatMan( def: CommandDefinition | undefined, name?: string, formats?: Formats, ): Block[]; export declare function checkFields( def: CommandDefinition, fields: string[], ): string | null; export declare function selectFields( data: unknown, fields: string[], schema?: Schema, ): { data?: unknown; error?: string }; ``` ## `@d34dman/sutram/console` The console, . ```ts /** The console's own texts. Functions take the values they mention. */ export interface Messages { title: string; count: (n: number) => string; label: string; placeholder: string; inputLabel: string; copy: string; copyTitle: string; copied: string; copyFailed: string; prompt: string; promptTitle: string; close: string; closeLabel: string; yes: string; no: string; answered: (answer: Answer) => string; /** The question before a command without its own; gets its title when it has one. */ run: (name: string, title?: string) => string; cancelled: string; /** Shown after a question whose call was cancelled before anyone answered. */ withdrawn: string; answerHint: string; manWhich: string; manMissing: (name: string) => string; noStatus: string; pasted: (n: number) => string; batchConsequential: (n: number, names: string[]) => string; batchChanges: (n: number) => string; batchCancelled: string; batchMistake: (line: number, n: number) => string; batchNotLast: (name: string) => string; batchStopped: (line: number, n: number) => string; batchUndone: (line: number, n: number) => string; batchKept: (providers: string[]) => string; /** Shown before the commands of an agent's request the person declined in the approval dialog. */ agentDeclined: string; /** The label of the visually hidden log that mirrors the output in the page. */ logLabel: string; } export declare const MESSAGES: Readonly; /** The key left of 1, by position, outside text fields; Ctrl with it anywhere. */ export declare const DEFAULT_HOTKEY: "backquote ctrl+backquote"; export interface HotkeyBinding { binding: string; key: string; ctrl: boolean; alt: boolean; shift: boolean; meta: boolean; } /** * Reads space-separated bindings: modifiers (ctrl, alt, shift, meta, mod) and a * key. Letters and digits are the typed character; other keys are named like * KeyboardEvent.code in lowercase (backquote, slash, f2). "none" is no binding. */ export declare function parseHotkey(spec?: string): HotkeyBinding[]; /** The binding a key event matches, or null. Bindings without ctrl, alt or meta never fire in a text field. */ export declare function matchHotkey( e: Pick, bindings: HotkeyBinding[], options?: { inField?: boolean }, ): HotkeyBinding | null; export declare class SutramConsole extends HTMLElement { registry: Registry; /** Assign some of MESSAGES' keys to translate them. */ get messages(): Messages; set messages(value: Partial); readonly inline: boolean; /** The bindings of the hotkey attribute, or of DEFAULT_HOTKEY. */ readonly hotkeys: HotkeyBinding[]; /** The key held to peek, from the peek attribute; null when the console does not peek. */ readonly peekKey: "Alt" | "Control" | "Shift" | "Meta" | null; /** True while the console is faded by peek (the peeking attribute). */ readonly peeking: boolean; /** Lines run, newest last: this page's, after the history store's. */ history: string[]; /** Where history is kept between pages. Null (the default): only on this page. */ historyStore: HistoryStore | null; open(): void; close(): void; toggle(): void; /** Replaces what is in the input field, for example to suggest a command. */ setInput(value: string): void; /** Runs one line as if it was typed. */ submit(line: string): Promise; /** Runs several lines as one batch, as a paste does. */ runBatch(text: string): Promise; print(text: string, kind?: string): HTMLElement; /** Waits its turn when another question is open; `lead` prints what it is about when it shows. */ ask( message: string, options?: { lead?: (() => void) | null; signal?: AbortSignal }, ): Promise; /** The output as plain text, as the page's visually hidden log has it. */ transcript(): string; } /** * Keeps console history between pages. Sutram stores nothing itself; the app * decides where the lines go and for how long. */ export interface HistoryStore { /** The lines kept so far, oldest first. */ load(): string[] | Promise; /** Called with all lines, oldest first, after every line run. */ save(lines: string[]): void | Promise; } export declare function defineConsole(tag?: string): void; export interface MountAttributes { greeting?: string; locale?: string; /** Space-separated bindings such as "mod+k ctrl+backquote", or "none". Default: DEFAULT_HOTKEY. */ hotkey?: string; /** Hold this key alone to fade the console and see the page: "alt", "ctrl", "shift" or "meta"; true means "alt". Off by default. */ peek?: boolean | "alt" | "ctrl" | "shift" | "meta"; messages?: Partial; /** Keeps history between pages. Without it, history lasts as long as the page. */ historyStore?: HistoryStore; [attribute: string]: unknown; } /** Without a container: the page's drop-down console. With one: a new inline console inside it. */ export declare function mountConsole( attributes?: MountAttributes, container?: Element | null, ): SutramConsole; ``` ## `@d34dman/sutram/approval` The approval dialog, : where the person approves what an agent wants to run. ```ts /** The approval dialog's own texts. Functions take the values they mention. */ export interface ApprovalMessages { label: string; yes: string; no: string; /** The question before a command without its own; gets its title when it has one. */ run: (name: string, title?: string) => string; agentAsks: string; agentAsksBatch: (n: number) => string; agentBatch: (n: number) => string; agentBatchConsequential: (n: number, names: string[]) => string; /** A batch step's own question, under the batch's; before is true when earlier steps may change what it says. */ stepAsks: (name: string, question: string, before: boolean) => string; } export declare const APPROVAL_MESSAGES: Readonly; /** What the dialog says about a request: the lead, one shortened line per command, and the question. */ export declare function approvalText( steps: AskRequest["steps"], messages?: Partial, ): { lead: string; lines: string[]; question: string }; /** * : a modal dialog where the person approves what an agent * wants to run. No has the focus; Escape answers no. Fires sutram-ask and * sutram-answer (bubbling, composed) with the steps and the question or answer. */ export declare class SutramApproval extends HTMLElement { get messages(): ApprovalMessages; set messages(value: Partial); /** Asks; questions asked while one is open wait their turn. An aborted call's question is withdrawn: no. */ ask(request: AskRequest): Promise; /** Answers the open question. */ answer(yes: boolean): void; } export declare function defineApproval(tag?: string): void; /** * Makes the approval dialog the policy's asker unless the page has one; the * dialog is added to the page when it first asks. Returns a function that * removes it as the asker. The console and dist/sutram.js call it. */ export declare function useApprovalDialog(registry?: Registry): () => void; ``` ## `@d34dman/sutram/webmcp` Commands as WebMCP tools, tools() and callTool(). ```ts export interface Tool { name: string; /** A short name for people, such as "Save a form". */ title?: string; description: string; inputSchema: Schema; annotations: { readOnlyHint?: boolean; consequentialHint?: boolean; untrustedContentHint?: boolean; }; } export interface ModelContextLike { registerTool( tool: Tool & { execute: ( input: any, options?: { signal?: AbortSignal }, ) => Promise }, options: { signal: AbortSignal }, ): unknown; } export declare const STATUS_TOOL: Tool; export declare const BATCH_TOOL: Tool; /** Added to an agent's result when the run started Sutram again: wait for ready, then look again. */ export declare const REINITIALIZED_NOTE: string; export declare function findModelContext(): ModelContextLike | null; export declare function toolName(def: CommandDefinition): string; export declare function toTool(def: CommandDefinition, formats?: Formats): Tool; export declare function listTools(registry?: Registry): Tool[]; /** * Converts strings to the declared types, like console text; WebMCP calls do not. Follows the page's policy * for an agent: a call that needs approval is asked about through the page's asker, and does not run * when the page has none. */ export declare function callTool( name: string, input?: Record, options?: { registry?: Registry; signal?: AbortSignal }, ): Promise }; }>; /** sutram_batch with quiet: true: no results, only the one that failed. */ export type QuietBatchResult = Omit & { failed?: Result }; /** * Runs command lines as pasted into the console: several lines as one batch, * checked first and approved once. Follows the page's policy, as callTool does. Resolves to the * console's text, each line after "› ". */ export declare function execLines( text: string, options?: { registry?: Registry; signal?: AbortSignal }, ): Promise<{ code: number; text: string; completed: number; total: number }>; export declare function connectWebMCP( registry?: Registry, modelContext?: ModelContextLike | null, ): () => void; ``` ## `@d34dman/sutram/events` Declared events, and the events list and events recent commands. ```ts export interface SutramEvent

> { id: number; name: string; payload: P; source: string; heardBy: string[]; } export interface Events { define(name: string, def: { description?: string; payload?: Schema }): void; emit

>( name: string, payload?: P, options?: { source?: string }, ): SutramEvent

; on( name: string, fn: (event: SutramEvent) => void, options?: { as?: string; hidden?: boolean }, ): () => void; /** * The first event named `name` ("*" for any) with an id above `since`: one * still kept, or else the next one emitted. `since` defaults to the newest * id. Resolves to null after `timeout` ms (default 10000) or on abort. */ next( name?: string, options?: { since?: number; timeout?: number; signal?: AbortSignal }, ): Promise | null>; list(): Array<{ name: string; description?: string }>; lastId(): number; clear(upTo?: number): void; } export declare function createEvents( registry: Registry, options?: { bufferSize?: number }, ): Events; export declare function getEvents(registry?: Registry): Events; ``` ## `@d34dman/sutram/browser` globalThis.Sutram: the registry and its helpers in one object, with /client as Sutram.client. Also dist/sutram.js. ```ts export type SutramGlobal = { EXIT: typeof core.EXIT; CommandError: typeof core.CommandError; registry: core.Registry; events: Events; /** Resolves once the page's scripts have run and their commands are registered. Wait for it before the first call on every page, and after a run that started Sutram again. */ readonly ready: Promise; /** For a single-page app: its new commands are registered after Sutram started again. */ initialized(): void; formats: core.Formats; defineFormat(name: string, def: core.FormatDefinition): () => void; register: core.Registry["register"]; tools(): Tool[]; call( name: string, input?: Record, options?: { signal?: AbortSignal }, ): ReturnType; /** Command lines as pasted into the console: several as one batch. Answers with the console's text. */ exec( text: string, options?: { signal?: AbortSignal }, ): ReturnType; /** The agent prompt for this page, with its commands: the site's own (defineAgentPrompt) or the default. page defaults to document.title and location.href. */ agentPrompt(task?: string, page?: { title?: string; url?: string }): string; /** core.defineAgentPrompt for this page's registry. */ defineAgentPrompt(make: (task: string, page: core.AgentPromptPage) => string): () => void; mountConsole: typeof mountConsole; connectWebMCP(): () => void; /** @d34dman/sutram/client, for pages that build a client of their own. */ client: typeof client; /** Kits loaded as their own files: dist/nav.js adds nav, dist/page.js page, and so on. */ kits: { nav?: typeof nav; page?: typeof page; user?: typeof user; messages?: typeof messages; lists?: typeof lists; forms?: typeof forms; }; }; declare global { // eslint-disable-next-line no-var var Sutram: SutramGlobal; } declare const Sutram: SutramGlobal; export default Sutram; ``` ## `@d34dman/sutram/internals` Helpers Sutram uses itself. No semver promise: any release may change them. ```ts // Declarations for @d34dman/sutram/internals: helpers Sutram uses itself. // No semver promise. export declare function deref(schema: Schema, root: Schema): Schema | undefined; export declare function distance(a: string, b: string): number; export declare function formatValue( value: unknown, prop?: Schema, options?: FormatOptions, ): string; export declare function formatTable( rows: Record[], itemSchema: Schema, root?: Schema, options?: FormatOptions & { columns?: string[]; maxWidth?: number }, ): { head: string; rows: string[] }; export declare function synopsis(def: CommandDefinition): string; export declare function longSynopsis(def: CommandDefinition): string | null; export declare function annotationLabel(annotations?: Annotations): | "read-only" | "consequential" | "navigates" | null; export declare function describeShape( schema: Schema, root?: Schema, depth?: number, ): string; export declare function sentence(s: string): string; export declare function formatsUsed( def: CommandDefinition, formats?: Formats, ): Array<{ name: string } & FormatDefinition>; ``` --- Source: https://d34dman.github.io/sutram/docs/reference/command-line.md # The command line Sutram parses one command line at a time. It is not a shell: no pipes, redirection, variables or globbing. ## Grammar ``` [positional …] [option …] [-- value …] option: --name=value --name value --flag --no-flag ``` - **The command name** is one to six lowercase words: `cart add`, `form set`. The longest registered name wins. - **Positional values** fill the keys listed in `positional` that were not given as options, in order. The last one takes the rest of the line: `nav search menu settings` searches for "menu settings". A word with no key left is a usage error (`unexpected argument`), never dropped. - **Options** are GNU long options: `--qty=2` or `--qty 2`. There are no short options except `-h`. - **Booleans**: `--all` sets true, `--no-all` sets false. `--all=false` works too; `--all false` is a usage error, so the word is not taken for an argument. - **`--`** ends options: everything after it is a value, even if it starts with `--`. - **Lists** are comma-separated: `--tags=draft,urgent`. A list whose items contain commas, or a list of objects, is written as JSON: `--tags='["a,b","c"]'`. **Objects** are always JSON: `--meta='{"a":1}'`. ## Quoting - Double or single quotes group words: `--who="Grace Hopper"`, `set title 'Pour-over, step by step'`. - A backslash escapes the next character, except inside single quotes. - A backslash at the end of a line joins the next line to it, as in a shell: long commands can be split over several lines. In the console, Enter after a trailing backslash starts a new line. - An apostrophe opens a single quote, so `don't` in an unquoted value is an unclosed quote. Put such text in double quotes: `nav search "don't panic"`. - A value that starts with a quote is never an option. - An unclosed quote is a usage error (exit 2). In the console, Enter inside quotes starts a new line of the value instead. ## Heredocs For long or multi-line text, such as the HTML of a rich-text field, end the line with `<Don't escape anything here.

  • "Quotes" and \backslashes\ stay as they are.
END ``` - Any word works as the closing word: `<Enter inside one starts a new line. - [`commandLine()`](api.md) writes a multi-line value back as a heredoc, so what a person is asked to confirm stays readable. ## Several lines Pasted text and multi-line entries are split into lines and run as one [batch](../concepts/batches.md). Line breaks inside quotes or a heredoc belong to the value. Lines starting with `#` are comments. ## Options every command has | Option | Does | |---|---| | `-h`, `--help` | Show the command's usage | | `--json` | Print the data unchanged, as agents get it | | `--json=a,b` | Print only those fields: of the record, else of each row in its lists | | `--wide` | Show every column | A command cannot declare inputs named `help`, `json` or `wide`. ## Words clients own | Word | Does | |---|---| | `help`, `help ` | List commands | | `man ` | Show a command's manual | | `status`, `status ` | Run every ` status`, or only those | | `clear` | Clear the console | Command names cannot start with these words, or with `sutram`, which Sutram's own WebMCP tools (`sutram_status`, `sutram_batch`) use. ## Exit codes | Code | Name | Means | |---|---|---| | `0` | `EXIT.OK` | Done | | `1` | `EXIT.FAILED` | Ran, but failed | | `2` | `EXIT.USAGE` | The input did not fit: unknown option, wrong type, missing value, bad quoting | | `127` | `EXIT.NOT_FOUND` | No such command, with "did you mean" when one is close | | `130` | `EXIT.CANCELLED` | Cancelled, or not allowed to run without approval | ## Annotations | Annotation | `help` shows | WebMCP hint | |---|---|---| | `readOnly: true` | `[read-only]` | `readOnlyHint` | | `consequential: true` | `[consequential]` | `consequentialHint` | | `navigates: true` | `[navigates]` | in the description | | `reinitializes: true` | `[reinitializes]` | in the description | | `untrustedContent: true` | | `untrustedContentHint` | ## Tool names A WebMCP tool's name is the command name with spaces replaced by underscores: `cart add` → `cart_add`, `form set` → `form_set`. `commandLine(def, input)` turns a tool call back into the command line a person would type, and that line parses to the same input: objects and awkward lists as JSON, `null` left out. --- Source: https://d34dman.github.io/sutram/docs/why.md # Why commands Without Sutram, an agent that wants to use a web app has two options. - **Read the page.** Take screenshots or an accessibility snapshot, work out what is on the page, find the right elements, click and type, and read the page again to see whether it worked. It is slow, it costs tokens on every step, and it breaks when the layout changes. A headless browser and the user's own browser behave differently, so the experience is not the same either. - **Call an API built for agents.** Someone writes a tool, with a schema, for every action. That is work for every form and every page, and it usually means a new way into the server, with its own login. Sutram is the third option: **a standard interface on the page itself.** Any part of the app (a form, the navigation, an editor, a module) hooks in the same way, by registering commands. Agents find them with `help`, run them one at a time or in one batch, and correct what goes wrong: ```sh form fields node-article-form form set node-article-form title "Pour-over, step by step" form set node-article-form field_tags "coffee, v60" form set node-article-form status false form submit node-article-form ``` Each command has a JSON Schema for its input, and agents with WebMCP get it as a tool. So an agent can also send everything in one go first, and fix single values after. ## In the user's session Commands run in the browser, as the logged-in user. A person can log in, then let an agent take over: it can do exactly what the person could already do, and nothing more. There is no new API, no extra login and no service account. Access checks stay on the server, where they are today. ## The same everywhere The same commands answer the same way whether the agent drives a headless browser, uses WebMCP in the person's browser, runs JavaScript in the page, or types into the console. Nothing depends on screen size, layout or how the page looks. ## Precise edits `form set title "Foo"` changes one field and nothing else. With one schema for the whole form, the agent writes every field, including the ones it did not mean to touch, and one wrong value fails or overwrites all of it. For an existing article with a long body, that matters more: changing the title should not mean reading the body and sending it back. ## Forms that change as you fill them Real forms are not fixed. Pick "billing" and the form asks for an invoice number. Tick "call me back" and it wants a phone number. Drupal has conditional fields, AJAX widgets, paragraphs and multi-step forms. A schema written up front cannot describe fields that do not exist yet. With commands the agent answers, and the result says what appeared: ``` › form set request topic billing topic set. The form now also asks for: invoice, amount. ``` The [forms example](https://d34dman.github.io/sutram/example/forms.html) does exactly this. The coffee shop on the home page does it at small scale: the `checkout` command exists only once the cart has something in it. ## Errors the agent can act on When a command fails, the message names the command and what is wrong: ``` › form set node-article-form status draft form set: status is a yes/no field. Use true or false. ``` The agent fixes that one line and carries on. The fields it already set stay set. With one schema, a rejected call means nothing was applied, and the agent sends the whole object again. Exit codes say what kind of failure it was: `2` for input that did not fit, `127` for a command that does not exist (with "did you mean"), `1` for a command that ran and failed, `130` for one that was not allowed to run. See [Results and exit codes](concepts/results.md). ## Control, and a person in the loop - **Read first.** `status` shows what every part of the page reports. Commands marked read-only change nothing. - **Ask before what matters.** Annotations mark what is consequential, and the page's policy decides who has to agree. See [Annotations and the policy](concepts/annotations-and-policy.md). - **Save on purpose.** Changes go into the page's working copy. `submit` sends them; `discard` throws them away. Nothing is saved as a side effect. See [Drafts and saving](guides/drafts-and-saving.md). - **Readable by people.** Every agent call is a command line a person can read, repeat, paste into a bug report, or type themselves. ## What it costs in tokens Claude Code, driving a real browser on the Drupal demo, ran two tasks once with Sutram and once without it, on the same set-up. Building a Canvas page took half the task tokens (228k instead of 453k) and 21 calls instead of 28; editing an article took 11 calls instead of 14. One run each, so a direction, not a benchmark; the method and all numbers are in [Measurements](measurements.md). Where the saving comes from, estimated from captured outputs on Drupal's "Create Article" form with one wrong value (bytes ÷ 4, Sutram 0.3 and 0.4, no agent run): - **Reading the page:** a browser agent reads about 3,300 tokens of accessibility snapshot (about 81,000 as HTML, about 1,600 per screenshot). With Sutram, the agent reads about 650 tokens of `help` and field list, or about 1,200 through the full WebMCP tool list. - **Correcting one wrong value:** 17 to 44 tokens with Sutram. Without it, about 3,300: the browser agent gets no error message, so it reads the page again to find out what happened. - **Hidden fields:** four of the form's fields sit behind toggles. Sutram's field list shows them; a browser agent has to find them and click them open. A schema written by hand for this one form would read even less, but someone has to write it for every form, and it resends every value after one mistake. ## Why not just WebMCP? WebMCP is how a page hands tools to an agent. It does not say which tools a page has, how they are named, or how many parts of an app share one page. Sutram is that layer, and WebMCP is one of the ways out of it. - **Many providers, one page.** On a Drupal page a dozen modules may each want to expose something. With plain WebMCP each registers its own tools, with its own names. With Sutram they share one registry and one command grammar. - **People see what agents see.** WebMCP tools are invisible to people. Every Sutram command is also a line in the console, so a person can type exactly what the agent would call and watch what it runs. - **Few tools, stable list.** `form set …` is the tool `form_set` for every form, not one tool per form. Commands look up their target when they run, so the tool list stays put while content comes and goes. - **What WebMCP leaves out.** Discovery (`help`, `man`, status), errors that name the fix, changes to poll ([events](concepts/events.md)) and app-defined [formats](concepts/formats.md). - **Works before WebMCP does.** Browser agents and tests call the same commands through `Sutram.call()` today. Sutram registers its tools through WebMCP's own `registerTool()` and never reads or changes the page's HTML. It does not replace Playwright or browser agents: they drive the browser, and Sutram gives them commands to call instead of buttons to click. ## When one tool is enough For one app with a few fixed actions, a plain WebMCP tool with a schema is fine. That is exactly what each Sutram command is through WebMCP. Sutram earns its place when several parts of an app share a page, or when people should use the same commands as the agent. ## Next - [Quick start](quick-start.md): a command in five minutes. - [Forms](guides/forms.md): field-level commands and progressive forms. - [Write descriptions agents can use](guides/descriptions.md). --- Source: https://d34dman.github.io/sutram/docs/measurements.md # Measurements What does Sutram save an agent? This page compares an agent with Sutram against the same agent without it, on the same task, so the [case for commands](why.md) rests on numbers, not adjectives. ## Agent runs Claude Code (Opus 5.5) driving a real Chromium through Playwright on the [demo site](https://github.com/d34dman/drupal-sutram-demo), 25 and 26 October 2026, with a development build of Sutram 0.9. Each task ran once with Sutram and once without it, on the same set-up, and every run passed, graded from Drupal. | Task | Calls saved | Task tokens saved | |---|--:|--:| | Build a Canvas page, starting logged out on the homepage | 7 of 28 (25%) | 225k of 453k (50%) | | Edit an article: title, summary, body, tags, unpublished | 3 of 14 (21%) | not split out; prompt tokens 382k → 245k | - **Task tokens** are prompt tokens without the harness's own share of each call (its system prompt and tools, about 18k per call in Claude Code), which Sutram cannot change. Totals depend mostly on that share, so they do not compare across harnesses. - **One run each: a direction, not a benchmark.** Another agent, another harness or another task can come out differently: an earlier outside evaluation, with Sutram 0.6 and agents that read the console off the screen, used more calls with Sutram than without. Since then `agentPrompt()` sends agents to the page's tools first. - Most of the saving is the page itself. A browser agent keeps a snapshot of the page in its context, and every later call carries it; Sutram's answers are short text. The estimate below shows where that comes from. ## Where the saving comes from: an estimate How much does an agent read and write to fill in a form, and how much does a mistake cost? Measured in bytes from captured outputs, with no agent run. ### The setup - **The form:** Drupal's "Create Article" form on the [demo site](https://github.com/d34dman/drupal-sutram-demo), with the `sutram_demo_form` module. Eleven fields an agent can set. Four of them are hidden until clicked open: the summary behind "Edit summary", the URL alias and the promotion options in collapsed sections. Captured on 16 October 2026 in [`docs/measurements/`](https://d34dman.github.io/sutram/docs/measurements/drupal-article-form.json): Sutram's tool list, `help`, the field list and real outputs, and the page as a browser agent sees it. - **The task:** create an unpublished article with a title, a summary, a short body and two tags. - **The mistake:** one wrong value (the published flag), which has to be corrected. The ways to do it: - **C. A browser agent without Sutram.** It reads the page as an accessibility snapshot, the cheapest view a browser agent gets (cheaper than screenshots or HTML), then clicks and types field by field, using element references from the snapshot. When something goes wrong there is no error message: it reads the page again to find out. - **B1. Sutram through WebMCP.** The agent reads the page's tools and calls `form_node-article-form_set` once per field, then `save`. - **B2. Sutram in the console.** The agent reads `help` and `form node-article-form fields`, and writes command lines. A pasted batch stops at the failing line, so the correction is that line and the ones after it. - **B3. Sutram, everything at once.** Every field in one `sutram_batch` call. The batch stops at the wrong value, the fields before it stay set, and the agent sends the rest again from the corrected call. - **A. For reference: one schema for the whole form.** Drupal has no such tool. It is built here from the field data Sutram's form module already provides, as small as it can be, and handed to the agent for free. It shows the floor: even the best hand-made schema resends everything after one mistake. ### The numbers > These numbers were captured on 16 October 2026 with Sutram 0.3 and 0.4, > before the forms kit. Then each form had tools of its own > (`form_node-article-form_set`) and the send command was `save`; today it > is one `form_set` tool with the form as an argument, and `form submit`. > The ratios should hold, but the figures have not been measured again yet. Bytes, and tokens estimated as bytes ÷ 4. Real tokenizers differ; the ratios between the rows hold. Form: form node-article-form, 11 fields an agent can set. Captured 2026-10-16, drupal-sutram-demo (Drupal 11.4, drupal/sutram 1.x-dev with sutram_demo_form), node/add/article, Sutram 0.3 + 0.4 work. | Way | Reads first | Writes | Correcting one wrong value | |---|--:|--:|--:| | C. Browser agent, no Sutram (accessibility snapshot) | 13089 B (≈3272 tokens) | 733 B (≈183) | 13162 B (≈3291) | | B1. Sutram through WebMCP, one call per field | 4938 B (≈1235 tokens) | 645 B (≈161) | 129 B (≈32) | | B2. Sutram in the console (help, fields) | 2584 B (≈646 tokens) | 431 B (≈108) | 67 B (≈17) | | B3. Sutram, every field in one sutram_batch, then corrections | 4938 B (≈1235 tokens) | 694 B (≈174) | 174 B (≈44) | | A. For reference: a hand-made form-wide tool, whole form | 982 B (≈246 tokens) | 389 B (≈97) | 387 B (≈97) | | A′. The same, only the fields it changes | 982 B (≈246 tokens) | 281 B (≈70) | 279 B (≈70) | Discovering the whole page (14 commands): the tool list is 4938 B (≈1235 tokens), help is 1664 B (≈416). The same page as raw HTML is 324175 B (≈81044 tokens). A screenshot of the 1360×900 viewport costs ≈1632 tokens, and the form takes 2. 4 fields are hidden until clicked open (body.summary: Edit summary; path.alias: URL alias (collapsed); promote: Promotion options (collapsed); sticky: Promotion options (collapsed)); C's numbers count no extra reads for them. The wrong value, as the form command reports it: "form node-article-form set: status is a yes/no field. Use true or false." ### What it shows - **Reading the page costs a browser agent 2.5 to 5 times more** than reading Sutram's commands, and that is with the cheapest view. As HTML the page is about 81,000 tokens; as screenshots, about 1,600 tokens per screen. Sutram exposes the same page, form and navigation alike, as one short list. - **A correction costs 17 to 44 tokens with Sutram** and about 3,300 without it, because the browser agent gets no error and has to read the page again to see what happened. With Sutram the error names the field and the fix. - **Hidden fields are free with Sutram.** `fields` lists all eleven, including the four the page hides behind toggles. The browser agent has to find them, click them open and read again; the numbers above do not even count that. - **Sutram lets the agent choose.** Send everything in one call (B3), or one field at a time (B1, B2). Either way it corrects one value, not the whole form. - **A hand-made schema (A) reads less, but someone has to write and maintain it for every form**, and it still resends every value after one mistake. Here it was built from Sutram's own field data. - **It leaves out what every call carries:** the harness's own prompt and tools, and the page snapshot a browser agent keeps in its context. In a real run those decide the totals, which is why the runs above save less than these ratios suggest. ## Run it yourself ```sh node scripts/measure.mjs # the table above node scripts/measure.mjs --json # the numbers ``` The docs build puts the script's output between the markers on this page, and `npm run docs:check` fails when the page and the script disagree. To measure another form, capture the same fields from your page (see the comments in `scripts/measure.mjs`) and point the script at the file. --- Source: https://d34dman.github.io/sutram/docs/design.md # Sutram design Status: draft, 12 Oct 2026. Open questions are at the end. > **Update, 15 Oct 2026.** Open question 1 is answered: Sutram is an npm > package (this repository), and the Drupal integration lives in > `drupal-sutram-demo` for now. Some of this note has since changed, as > described in the package README: `describe` became `status`, `help` and > `man`; named arguments use GNU long options (`--at=hero-2`) instead of > `at:hero-2`; results carry exit codes and declared output; and annotations > outside WebMCP's fixed set go into the tool description instead of passing > through unchanged. > > **Update, 16 Oct 2026.** Autocomplete is built: commands may define > `suggest(key, partial)`. Who is asked before a command runs is now a page > policy per kind of caller (person or agent), not the annotation alone, and > WebMCP calls go through it. Batches run as one change when the provider > defines batch hooks, and agents get a `sutram_batch` tool. The console can > also sit inline in an app's own panel, and its texts can be translated. > These came out of the study of replacing the FlowDrop Console with Sutram. ## What Sutram is A standard way for JavaScript applications to expose their commands, so their authors never have to build or ship a console of their own. An app registers its commands once. Anyone on the page can then run them: - a person, in the Sutram console; - an AI agent, such as Claude through WebMCP; - anything else that can call JavaScript on the page. The contract is the product. The console is just one of its clients. ``` JS app authors Sutram Who runs the commands ────────────── ────── ───────────────────── Canvas ─┐ ┌─ a person, in the Sutram console FlowDrop ─┼─ register ─→ registry + context ─────┼─ an AI agent, via WebMCP Form API ─┤ commands parse, route, help └─ anything else that can call JS widgets ─┘ (one definition) ``` ### What Sutram is not - **Not a replacement for drush.** It runs in the browser, inside an app the user already has open. - **Not a server API.** Sutram never adds a way into the server. See [Security model](#security-model). ## Example On a Canvas page, the user opens the Sutram console: ``` › help canvas clear Remove every element from the canvas consequential canvas add block [at:] Add a block canvas list blocks List the blocks on the canvas read-only › canvas add block heading at:hero-2 › canvas clear Clear the canvas? This removes 14 elements. (y/n) ``` `canvas clear` runs entirely in the browser. Nothing is sent to Drupal. Canvas resets its own state, exactly as if the user had done it by hand. `at:hero-2` means nothing to Sutram. It parses the line into a command, positional arguments and named arguments, and hands them to the Canvas handler. Only Canvas knows what a ref is. ## Who owns what | Sutram owns (the shell) | The provider owns (Canvas, FlowDrop, a widget, …) | |---|---| | Opening it (a hotkey), the input line, output, history | The command definitions | | Parsing: words, positional args, `key:value` args, `--flags`, quoted text | What the arguments mean | | The registry, and which commands are available right now (context) | The handlers, which run in the browser and call the provider's own APIs | | `help`, `describe`, did-you-mean for unknown commands | Suggestions for its own values, such as the refs that can follow `at:` | | Autocomplete, which asks the provider for value suggestions | Whether and how its commands can be undone | | Exposing the same registry as WebMCP tools | | ## A command definition ```js sutram.register({ name: "canvas add block", description: "Add a block to the canvas", args: { type: { type: "string", enum: ["heading", "paragraph", "image"], positional: 0 }, at: { type: "string", description: "Where to insert it", suggest: (text) => canvas.refs(text) }, }, annotations: {}, run: (args, ctx) => canvas.addBlock(args.type, args.at), }); ``` The arguments are described as a schema. From that one definition Sutram produces both: - **for people:** text parsing, help and autocomplete: `canvas add block heading at:hero-2`; - **for agents:** a tool with structured arguments: `canvas_add_block({ type: "heading", at: "hero-2" })`. The handler receives the same parsed arguments either way, and does not know who called it. ## Discovery comes first Being able to find out what is possible matters as much as running commands. If an agent cannot find out what a page offers and what state it is in, it falls back to screenshots and guessing. Sutram makes discovery a core part of the contract, not an afterthought. There are three questions, each with its own command: | Question | Command | Example | |---|---|---| | What can I do here? | `help`, `help ` | `help`, `help canvas`, `help field_image` | | What is this, and what state is it in? | `describe ` | `describe field_image` | | What values could go here? | autocomplete, and `suggest` on each argument | the refs after `at:` | - `help` is generated from the command definitions, so it always matches what is actually available on the page right now. - `describe` returns **current state**, not just documentation. For a media library widget: how many items are selected, and each item's meta properties (ID, name, type, alt text, …). - Each answer is structured data. The console renders it as a readable table, and an agent gets the same data as JSON. ### The integration ladder How well an app or widget works with Sutram depends on how much of this contract it follows. The ladder makes that visible and gives integrators a clear next step: | Level | The integration… | What people and agents get | |---|---|---| | 0 · Listed | is visible to Sutram, with no integration | "No integration yet: raw value only." At least nobody has to guess. | | 1 · Described | answers `describe` with what it holds and its current state | Agents can read the page without screenshots | | 2 · Writable | supports get and set through its own logic, so the UI stays in sync | Agents can fill it reliably | | 3 · Commands | adds its own actions, with annotations | `field_image search "sunset"`, `canvas clear` | | 4 · Suggests | offers values for its arguments | Autocomplete for people, real choices for agents | This is a recommendation, not a requirement. Every level is useful on its own, and the higher an integration goes, the better it works. ## Annotations Commands can carry annotations. The set is open: - **Known today:** `readOnly` and `consequential`, the same terms as WebMCP's `readOnlyHint` and `consequentialHint`, so nothing needs translating. They give three tiers: | Annotation | Meaning | Example | |---|---|---| | `readOnly` | changes nothing | `canvas list blocks`, `describe` | | neither (the default) | an ordinary write you would not think twice about | `canvas add block`, `form … set` | | `consequential` | significant, or hard to take back | `canvas clear`, `form … submit` | `consequential` stays rare on purpose: it only means something while it marks the exception. `form submit` is not destructive, but it does matter, which is why the word is "consequential" and not "destructive". - **Possible later:** `slow`, `network` (talks to the server), `undoable`, … - **Unknown keys pass through unchanged.** An app can add its own, and any client that understands them can use them. A new annotation never needs a new Sutram release. Annotations describe a command. They do not enforce anything. Each client decides what to do with them: - **The console:** read-only lines run straight away, and a consequential line asks first. A pasted block is checked before anything runs. If it contains consequential lines, it asks **once** and names them: "2 of 5 lines are consequential: `canvas clear`, `canvas delete …`. Run all?" - **Agents (WebMCP):** annotations become the tool hints, so the agent's host can run read-only calls in a batch and ask the user before consequential ones. - **Scripts (`Sutram.call`):** headless browsers, tests and agents that run JavaScript in the page call tools by name. A consequential tool asks the person in the page's approval dialog, as a WebMCP call does; on a page with nobody to ask, it does not run. The registry's own `run()` stays neutral: it is for app code. ## Keeping clients up to date Every client sees the same live list of commands, with their annotations. Commands come and go as the page changes: a form opens, a selection changes, an AJAX rebuild replaces part of the page. When a command appears, disappears or changes its annotations, Sutram sends a change event. So: - `help` always matches what is available right now; - an agent never acts on a list that is out of date. ## Security model Sutram runs in the browser and can only do what the page's JavaScript can already do. That is a deliberate choice, and it means Sutram checks no permissions: - **It adds no way into the server.** A handler that talks to Drupal uses the app's own endpoints, with the user's session. Drupal checks access there, the same as when the user clicks the button. - **It cannot grant more than the user has.** Typing `canvas clear` is the same as clearing the canvas by hand. If a provider registers a command the user is not allowed to complete, the server refuses it the same way it would refuse the click. - **Third-party commands are not a new risk.** A script on the page that registers commands can already do anything those commands could. The one real change is that **agents** become callers, and they act quickly with all of the user's permissions. Annotations handle that: they let a client ask the user before a consequential command, without Sutram becoming a permission system. The policy is etiquette, not a lock: `registry.run()` skips the question, and code running in the page could click *Yes* or the page's buttons just as well. Access control stays on the server. ## Forms and widgets ### `sutram_form`: Form API as a provider A submodule that exposes Drupal forms as commands. Site builders choose which forms to expose, either per form ID in settings or with `#sutram` in code. - **Server side:** a form alter adds a description of each exposed form: its elements, labels, types, required flags, allowed options and descriptions. Form API knows far more than the rendered HTML, so autocomplete and agents get real choices instead of guessing. - **Browser side:** commands for each exposed form on the page fill the real form fields, so the page stays in sync with what was typed. ``` form node_article_form fields read-only form node_article_form set title "Hello" form node_article_form set status on form node_article_form submit consequential: asks first ``` `submit` is the same as clicking Save. Drupal validates and checks access as usual, and validation errors come back as command output. `sutram_form` handles plain elements itself: text fields, checkboxes, radios and selects. ### Widgets plug in themselves Complex widgets are the main reason this API should exist. Media library, entity autocomplete, CKEditor and date pickers are where agents struggle today. They take screenshots, guess which modal to open, and click through it. Sutram does not write adapters for other people's widgets. **Each widget integrates itself**, the same way apps register commands. The widget's author knows how it works: | Part | Example: media library on `field_image` | |---|---| | describe: what it holds, and its current state | "2 of up to 3 Image items: #12 *Sunset* (alt: …), #15 *Harbour* (alt: …)" | | get / set, through the widget's own logic | `set field_image media:12,media:15` | | its own commands | `field_image search "sunset"` (read-only), `field_image remove 15` (consequential) | | suggest | media matching what has been typed so far | `sutram_form` hands over to a widget's integration whenever one is registered. A widget with no integration is still listed at level 0 of the ladder. An agent can then see what it cannot do yet, and the list shows which integrations to build next. **To start with:** we build integrations for entity autocomplete, media library and date/time ourselves. They show contrib authors what a widget integration looks like. After that, each widget's own maintainers take over. ## Prior art | Project | What it does | Difference | |---|---|---| | [FlowDrop Console](https://flowdrop.io/blog/the-flowdrop-console) | A console for the FlowDrop editor | Sutram makes that idea general, for any app | | [Coffee](https://drupal.org/project/coffee) | An Alt+D palette for jumping to admin pages | It only navigates | | WebMCP form modules, and WebMCP's declarative form tools | Turn chosen forms into agent tools | One tool per form, forms only, agents only | | [agentk](https://github.com/stevysmith/agentk) | A flat catalog of MCP-style tools (`name`, `description`, `inputSchema`) for one app, shown in a React ⌘K palette with generated forms and registered with WebMCP. Optional agent mode with approval tiers from the WebMCP hints. | Tools first, with a form for each. No typed command line, one catalog owner instead of many providers, no `describe` of current state, no commands that come and go with page state. Its rule "one consequential call and the whole plan waits" matches our batch rule. | ## Open questions 1. **A general npm package or a Drupal-only module?** Nothing in the contract depends on Drupal, and widgets such as CKEditor and date pickers are general JavaScript. That favours an npm package with this Drupal module as its first host. A Drupal-only module is simpler to start. 2. **Namespaces.** Does every provider get its own top-level word (`canvas …`), with `help`, `describe` and `clear` reserved for Sutram? Can a page's main provider leave out its prefix? 3. **Argument syntax.** Is `key:value` the standard for named arguments, next to `--flags`? 4. **The first provider:** `sutram_form`, Canvas or FlowDrop?