sutram docs
All pages

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.
  • 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

import { getRegistry } from "@d34dman/sutram";

const remove = getRegistry().register({
  // "<provider> <verb>", 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

FieldRequiredWhat it is for
nameyesLowercase words separated by single spaces. Start with the provider: cart add, form set.
provideryesWho owns the command. help <provider> lists one group.
titlenoA 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.
descriptionyesWhat it does, in one line first. help shows the first line, man all of it; agents read all of it. See descriptions.
inputnoJSON 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.
annotationsyesHints such as readOnly and consequential. See annotations.
positionalnoInput keys that can be given without --name=, in order. The last takes the rest of the line.
outputnoJSON Schema for data. Drives tables and man, and tells agents what comes back. Data that does not fit logs a warning.
examplesnoCommand lines, shown by man and given to agents.
seeAlsonoRelated command names.
confirmno(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.
suggestno(key, partial, { input, signal }) returns values for completion. May be async.
viewno{ 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.
runyes(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.

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 <form> <field> <value>), 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.

A status command per provider

Give each provider a read-only <provider> 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.

This page as MarkdownEdit on GitHub