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.
mod.json
Section titled “mod.json”| 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 insideregister, 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. eis 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.erroris{ kind, message }andnext.calledsays whether the hook had already callednext.
Events
Section titled “Events”| 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.submitseetextas you typed it. Matchers onpermission.askwork onkind,toolandtitle. - A
denyreason 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.submitorpermission.askhook gets 1.5 seconds. Fleet itself waits at most 2 seconds for the whole chain onprompt.submit,turn.completeandpermission.ask. After that the prompt goes on as you typed it and the ask goes to you; aturn.completethat runs over is simply not waited for. - Side conversations (
/btw) and subagent sessions don’t fireturn.complete.
Places
Section titled “Places”| 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.
Tool row props
Section titled “Tool row props”| 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 |
What $ has
Section titled “What $ has”$ 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.
Elements
Section titled “Elements”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,SelectandPageneeds akey, 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.
The static check
Section titled “The static check”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 asns.method, once each - every
$.statekey - every
Pagepath, 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.jsonis 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 usesrequire,import()orimport.meta - it reads a global outside JavaScript’s own, or one of
globalThis,window,process,Bun,fetch,setTimeout,eval,Function,Reflect,Proxyand 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 useswith onis called outsideregister, with a non-literal event or matcher, or for an event that doesn’t exist- a
$.statekey 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 and failure
Section titled “Order and failure”- 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
registercalledon. - Time. A hook gets 10 seconds of its own time per dispatch, not counting time inside
nextor a$call. A.catchhandler gets 1 second. Aprompt.submitorpermission.askhook 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 callingnextis 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,
$.stateis lost andsession.startfires withreason: "reload".
Limits
Section titled “Limits”| 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.
Where mods live
Section titled “Where mods live”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.
Where next
Section titled “Where next”- 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.