Skip to content
Weaveweave / fleet
Install Fleet
Docs/Extend/Mods API

Mods API

A reference for writing a Fleet mod by hand. The mod.json fields, every hook and event, the places a mod can draw, what $ gives you, the elements, the static check, limits and how a mod fails.

For a first example, see the worked test-chips mod. For finished mods to copy, see the showcase.

Field Required Meaning
name Yes Lowercase letters, digits and -, 1 to 64, starting with a letter. It matches the folder. Names starting fleet-, drafts, and Windows device names such as con or com1 are reserved.
version Yes Your version string. Fleet numbers kept versions itself (v1, v2) and shows yours beside it.
description Yes One line, 200 characters at most.
hooks Yes Path to the hooks module inside the folder: .js, .mjs, .ts or .mts.
panes No Up to 8 panes for the + menu: { "id", "title", "icon"? }. id is letters, digits, _ and - (up to 64). title is 1 to 40 characters. icon is an Icon name.

The module is one ES module that exports register. It may import type from fleet-mods and nothing else. A mod can also ship .html files, with their CSS, images and scripts, for the Page element.

import type { Register } from "fleet-mods";
export const register: Register = (on) => {
on("ui.render", { component: "ToolUse", props: { tool: "bash" } }, async ($, e, next) => {
return next(e);
});
};
  • on(event, matcher?, hook) registers a hook. Call it directly inside register, with a string literal event and an object literal matcher. Registering one event twice with no matcher is refused.
  • A matcher is compared field by field with e. A value matches itself, an array matches any of its values, a RegExp matches a string by pattern, and a nested object matches a nested field.
  • e is frozen. To change it, pass a copy: next({ ...e, text: "..." }).
  • next(e) runs the rest of the chain (later mods, then Fleet) and gives you its result. Return it to observe, return your own tree to answer.
  • .catch(handler) on a registration runs when the hook throws, times out or returns something the event doesn’t take. Inside it, next.error is { kind, message } and next.called says whether the hook had already called next.
Event Fires when A hook can return
session.start Once per mod per session before its first other event, and again after a reload (reason: start or reload) next(e)
turn.complete A turn finished, was stopped (isAborted) or failed (isFailed, failure). changedFiles lists the files edited next(e). Watch only
prompt.submit You sent a prompt, before the agent gets it. e has text and context next({ ...e, text }) to reword, next({ ...e, context: [...e.context, "..."] }) to add notes for the model
permission.ask An agent asked for something you’d answer. e has kind (shell, edit, web, read, other), tool, title, patterns { decision: "deny", reason }, or next(e) to let you answer
ui.render Fleet is about to draw a place the mod hooked A tree, null to draw nothing, or next(e)
ui.press A Button the mod drew is pressed next(e), or { element } to swallow it
ui.input An Input changes (kind: "change") or is submitted ("submit") next({ ...e, value }), or { element, value } to swallow it
ui.select A Select changes The same, with value

ui.press, ui.input and ui.select carry sessionId, mod, element (the key) and surface. At the end of their chain, the control’s own callback (onPress, onSubmit, onInput, onSelect) runs in the mod that drew it. The other events from prompt.submit on are 0.51.0; session.start and ui.render are 0.50.0.

How prompt.submit, turn.complete, permission.ask and their limits behave is on the Mods page. In short:

  • Matchers on prompt.submit see text as you typed it. Matchers on permission.ask work on kind, tool and title.
  • A deny reason is a non-empty string of at most 500 characters. Anything else counts as a failure and the ask goes on to you.
  • $.prompt.submit({ text }) queues a message and rejects with Fleet’s reason when refused: empty, over 100,000 characters, the session is archived, or the cap of 3 in a row.
  • Each hook may run for 10 seconds. A prompt.submit or permission.ask hook gets 1.5 seconds. Fleet itself waits at most 2 seconds for the whole chain on prompt.submit, turn.complete and permission.ask. After that the prompt goes on as you typed it and the ask goes to you; a turn.complete that runs over is simply not waited for.
  • Side conversations (/btw) and subagent sessions don’t fire turn.complete.
Place Where it draws e.requestId e.props
ToolUse A tool row’s line, after Fleet’s label. Inline elements only The tool call id Tool row props
ToolResult The body of an opened tool row The tool call id Tool row props
Pane A tab beside Changes and Files, or a sheet on the phone The pane’s id title
ComposerBand A band above the composer, on the desktop and the phone The session id none
StatusChip A chip at the end of the status bar, while its session is on screen The session id none

next(e) at a place gives you what the rest of the chain draws. A tree you return replaces it. To keep Fleet’s own body at ToolResult, put await next(e) inside a Box of yours, at most once.

Inline elements are Text, Pill, Icon, Button and a row Box holding only those. Anything else at ToolUse makes the tree invalid.

Fleet draws a tool row’s places when the row first shows, when the call’s status changes, and when the mod calls $.ui.invalidate. It never redraws on streamed output, so a running call’s output is what had arrived at its last status change. Renders are per session: desktop, phone and other tabs get the same tree.

Field Meaning
tool Fleet’s canonical name, the same on every agent: bash, read, edit, task…
rawTool The name as the agent sent it
category read, search, edit, shell, web, subagent, question, plan, skill, fleet or other
status pending, running, completed, error or cancelled
title The row’s title
input, inputTruncated The arguments. {} and true when over 64 KiB of JSON
output, outputTruncated The output text. The last 256 KiB and true when longer
durationMs Set once the call has finished

$ is the only way out of a mod. Write each call in full, like $.state.get("count").

Call Notes
$.mod.name, $.mod.version version is Fleet’s number, or "draft"
$.ui.resolve(e) The element factories: const { Box, Text, Pill } = $.ui.resolve(e)
$.ui.invalidate("ui.render") Draw this mod’s places again. Up to 10 a second; calls in between merge
$.ui.open({ id, title? }), $.ui.close({ id }) Open and close a pane (0.51.0). Dropped if no browser shows the session
$.ui.log(text, { level? }) A line in the mod’s log in Settings → Mods
$.ui.toast(...) Accepted, but shows nothing yet
$.state.get(key), .set(key, value) JSON per mod per session, up to 1 MiB. Keys are string literals. A ui.render hook that reads a key redraws when it’s written, and can’t write. Lost when the session is archived or the host restarts
$.store.get, set, delete, keys JSON per user per mod, saved by Fleet, up to 4 MiB. Async
$.session.id(), title(), harness(), cwd(), branch() Async. branch() is null outside a git repository or on a detached HEAD
$.session.surfaces() Answers an empty list today
$.prompt.submit({ text }) Queue a message in the session (0.51.0)
$.clock.now(), after(ms, fn), every(ms, fn) Timers stop when the module reloads or the session is archived. every is 100 ms at least, 20 timers per mod per session

There is no file, process, network or model access, and no setTimeout. Use $.clock.

Call $.ui.open and $.ui.close from a callback or an event, not while drawing. A ui.render hook that calls them throws. A $.ui.open in session.start runs again every time the host restarts.

Fleet draws a tree with its own components and theme, on desktop and phone. A mod never runs script in Fleet’s page.

Element Props
Box key, flexDirection (row or column), gap, padding, paddingX, paddingY, alignItems, justifyContent, flexWrap, flexGrow (0 or 1), width (a percentage), borderStyle (round, single, dashed, quote), borderColor, background (subtle, tint), children. Spacing steps are 0, 1, 2, 3, 4, 6, 8 (1 is 4 px)
Text color, bold, italic, strikethrough, code, dimColor, wrap (wrap or truncate), children (strings, numbers, Text)
Pill tone (good, warn, bad, neutral, accent), label, icon?
Icon name, color?, label?
Button key, label, onPress, icon?, tone? (primary, danger, quiet), disabled?
Input key, label?, placeholder?, value?, submitLabel?, onSubmit?, onInput?
Select key, label?, options (1 to 200 of { value, label }, values unique), value?, onSelect
Markdown text, key?, dimColor?
Code source, language?, path?, startLine?, format? (source or diff), wrap?
Page key, path, title, query?. An .html file in the mod’s folder, drawn sandboxed with Fleet’s theme as --fleet-* variables. It can’t call back into the mod
  • Icon names: check, x, alert, info, circle, dot, clock, loader, play, skip, test, bug, terminal, file, folder, git-branch, search, sparkles, zap, gauge, arrow-right, chevron-right, external-link, copy, eye, eye-off.
  • Colours are roles: text, muted, accent, good, warn, bad. No hex values.
  • Keys: every Button, Input, Select and Page needs a key, unique in the tree. Letters, digits, _, - and ., up to 64.
  • Invalid trees: an unknown element or prop, a missing or duplicate key, a non-inline element at an inline place, or a tree over the limits. Fleet draws the place as if the mod weren’t there and counts a failure. The reason is in the mod’s log. Text past 100,000 characters per tree is cut, not refused.

Before a module loads, Fleet reads it without running it. The same report is what you see as “what this mod touches” when you keep a mod. It lists:

  • every on(...) call with its matcher
  • every $ call as ns.method, once each
  • every $.state key
  • every Page path, checked to exist inside the folder
  • the panes in mod.json
  • errors and warnings with line and column

A module doesn’t load when:

  • mod.json is missing, invalid, or names a module outside the folder
  • it can’t be parsed as an ES module (TypeScript is stripped first)
  • it imports anything but import type ... from "fleet-mods", or uses require, import() or import.meta
  • it reads a global outside JavaScript’s own, or one of globalThis, window, process, Bun, fetch, setTimeout, eval, Function, Reflect, Proxy and similar
  • it uses $ in any way but a full $.ns.method(...) call: aliasing it, $[name], destructuring, spreading or storing it
  • it reads .constructor, .__proto__ or .prototype, or uses with
  • on is called outside register, with a non-literal event or matcher, or for an event that doesn’t exist
  • a $.state key isn’t a string literal
  • it is over 512 KiB or 2,000 nested scopes

This is not a sandbox. Every mod runs in one program, as you, and a hook that never returns blocks the others until Fleet restarts the program.

  • Order. Hooks on one event form a chain. Kept mods run first, by name, then the session’s drafts by name. A draft with a kept mod’s name takes its place in that session. Within a module, hooks run in the order register called on.
  • Time. A hook gets 10 seconds of its own time per dispatch, not counting time inside next or a $ call. A .catch handler gets 1 second. A prompt.submit or permission.ask hook gets 1.5 seconds. Past its time a hook is skipped.
  • A failure is a hook that throws, times out or returns something the event doesn’t take. With no .catch, a hook that failed before calling next is skipped and the next one runs in its place.
  • Three strikes. Three failures in a row from one mod, not caught by .catch, turn it off. Any success resets the count.
  • A blocked program. After 15 seconds on one dispatch, Fleet restarts the mod program and strikes the mod that was running. All mods reload, $.state is lost and session.start fires with reason: "reload".
Limit Value
Elements per tree / depth 2,000 / 32
A tree as JSON 256 KiB
Text per tree 100,000 characters
prompt.submit context 8 KiB in total
$.ui.toast text 500 characters
ui.input changes 4 a second per field
A hooks module 512 KiB

The rest are in the sections above.

Kept mods are stored under Fleet’s data folder, per user: mods/<user>/<name>/, with immutable v1/, v2/ folders, a versions.json and a store.json. Drafts are in mods/<user>/drafts/<session id>/<name>/. Undo changes which version is active and deletes nothing.

  • Mods covers turning mods on, keeping and undoing them, and what each hook does for you.
  • Mods showcase has finished mods and a prompt for each.
  • Troubleshooting is the place to look when something doesn’t work.