Introduction
PromptForge starts from one premise: human intent is the source code, and everything downstream of it - plans, prompts, reports - is a build artifact. Source is versioned; artifacts are regenerated. Regeneration costs a run, not a reconstruction.
This ordering follows from scarcity. Human judgment is the scarce resource; model output is abundant. So models advise and compare, and humans decide. Every design decision in the product traces back to that ordering.
The system protects the judgment you invest in two ways. It compiles the structural rules of your methodology into the runtime, so a rule cannot be forgotten under context pressure. And it records every run, edit, decision, and mistake in an append-only event store, so the judgment that built a pipeline is never lost.
The moving parts
The system has four parts, and the engine is the center.
The engine is a Rust library. It parses a Markdown prompt file and executes it as a program against any OpenAI-compatible endpoint. It gives you deterministic control flow, isolated sections, and engine-controlled fan-out.
The gateway is the one process that talks to model backends. It holds every credential, routes chat completions by capability name, manages the model catalog, and runs local models on your own hardware.
The Workshop is a standalone local desktop application. It wraps the engine in an environment where every run, edit, decision, and mistake is recorded in an append-only, hash-chained event store.
The library is the engine packaged as a dependency. An integrator embeds prompt execution in their own program; the Workshop itself is built on the library.
The parts connect in one direction. The Workshop and the library sit on the engine. The engine talks only to the gateway. The gateway fronts every model backend, local or frontier.
Which set is yours
Each audience has one documentation set.
If you use the Workshop desktop application, read the Workshop set. It teaches the workbench, the chat surface, the editor, voice input, models and profiles, and updates.
If you operate the gateway, read the Gateway set. It teaches installation, the configuration file, remote and local models, speech-to-text, profiles, and the operational surface.
If you write prompts, read the Prompt Language set. It teaches the .md prompt syntax: frontmatter, sections and blocks, Lua globals, prose substitution, models, tools, control flow, and fanout.
If you write agent programs, read the Agent Programs set. It teaches the .md agent surface: the agent loop, chat rounds, tool calls, the event log, host state, the sandbox, and the full loop.
The Workshop
- The Application
- The Workbench
- Menus and Commands
- The Status Bar
- Models and Profiles
- The Chat Surface
- Voice Input
- The Workspace
- The Editor
- Updates and Configuration
The Application
This chapter teaches you what the Workshop desktop application is, how to install and start it, and what you see the first time its window opens. Everything else in this guide happens inside this one window, so it is worth a few minutes to understand what the application is made of and how it boots before you touch any feature.
What the Workshop is
PromptForge Workshop is a desktop application for Windows, macOS, and Linux. You launch one program named Workshop. That program boots a small server inside itself and then opens a single window titled "PromptForge". The window shows the Workshop interface, which the built-in server serves on your own machine. There is no separate web server to install and no files to download before the interface can appear; the interface ships bundled inside the application.
The Workshop talks to a PromptForge gateway. The gateway is the part of the system that supplies the model catalog, the profiles, and the model rounds that power chat. The gateway runs as its own program, separate from the Workshop window: the application's built-in server attaches to a running gateway over HTTP, so closing the window never unloads the gateway or its loaded models. The window opens at 1024 by 768 pixels the first time, and it remembers its size, position, and maximized state across launches.
The application shows the PromptForge program icon in its custom title bar.
Installing and starting the Workshop
You receive the application as a Windows installer, a macOS disk image, a Debian package, or a Linux AppImage, depending on your platform. On Windows the installer silently includes the webview runtime the application needs, so there is no separate setup step.
To start the application, launch it the way you launch any installed program on your platform. If you work from a source checkout instead, one command builds and starts it:
cargo run -p workshop
To check which version you have without starting anything, run:
promptforge-workshop --version
This prints the version and exits. It does not start the server and it does not open a window.
The installed application can also check for updates and update itself. After startup it automatically checks the latest GitHub Release, and it installs only cryptographically verified updates.
You can also run the Workshop's server on its own and use the interface in an ordinary browser. In that mode you open the chat UI at http://127.0.0.1:7910/. The browser session works like the desktop window for almost everything; the few differences, such as native window controls and Explorer drag-and-drop, are called out in the chapters that cover them.
The first launch
The first time you start the Workshop, the application prepares everything it needs before you see a window. Follow what happens:
- The application looks for its boot configuration.
- It attaches to a running local gateway through its validated gateway discovery file. If none is running, it launches the sibling
promptforge-gateway; a Workshop-only install instead uses the explicit gateway inworkshop.toml. - It starts its server inside its own process and waits until the server accepts connections.
- It waits for the interface to answer a health check, up to 15 seconds.
- Only then does the window open.
You never see a window before the interface is ready, and the interface never opens against a dead server. If the server does not answer in time, the error message names the health endpoint and how long the application waited. If startup fails for any reason, the application prints the full error chain and exits with a failure code instead of opening a broken window.
Only one instance of the Workshop runs at a time. If you launch it again while it is already running, the existing window comes into focus instead of a second copy opening. When you close the window, the application shuts its built-in server down cleanly and exits; the gateway is a separate program and keeps running. To stop the gateway together with the window, use the quit command instead: Quit PromptForge and Gateway on the application menu, or Ctrl+Q (Cmd+Q on macOS). When the Workshop is attached to a gateway on another machine, the command reads Quit PromptForge and stops only the window - a client never stops a shared gateway. In-flight connections get a 5-second grace window, so a held chat session or a stuck request cannot hang the shutdown. The interface listens on an OS-assigned loopback port, so another program holding a port can never block startup.
The Workshop also keeps working when parts of its environment fail. After boot, if a local gateway exits, the application keeps the interface open while it looks for a validated replacement or relaunches the installed sibling with bounded backoff. A replacement is published only after its process identity, health response, and bearer key all validate, and the server switches its clients and credentials together. The same relaunch loop is how the Workshop restarts its supervised gateway on purpose: picking a profile from the Model menu persists the selection and then asks the gateway to shut down, and the relaunched sibling boots into the new profile. Explicitly configured gateways on another machine are never launched, supervised, or stopped by the Workshop. If microphone setup fails at startup, you keep working and only voice input stays unavailable. On Windows, if the bridge to Explorer fails to attach, the application keeps running and loses only Explorer drag-and-drop and the microphone grant.
The gateway configuration
The gateway owns its own boot config, gateway.toml, and the Workshop never reads it. On the gateway's first run - when no config exists anywhere it searches - the gateway writes a default gateway.toml into %USERPROFILE%\.promptforge\ and a sibling gateway.state.toml selecting the generated default profile. The generated catalog, profiles, and global settings all live in that one editable config file; the state file holds only the profile selection, which the gateway reads once at boot.
The generated config is a single editable TOML file with a header that invites edits. Two properties of the generated file are worth knowing:
- The gateway is secured with a freshly generated random bearer key, so no two installs share a key.
- The gateway listens on the loopback address only, on an OS-assigned port. It is not reachable from other machines, and the Workshop learns the port from the gateway discovery file the gateway writes.
A gateway.toml carried over from an older version may declare a [workshop] section with the inert bind and open_browser settings, which produce a deprecation warning because the Workshop's server now lives inside the desktop application. Speech pipeline tuning belongs in [stt]; legacy [workshop.stt] input is rejected as an unknown workshop field whether it appears alone or beside [stt].
Voice uses the same separation. The gateway owns speech models, worker lifecycle, batch transcription, and the generic Realtime endpoint. The Workshop server contributes only an authenticated same-origin relay, while the browser UI owns microphone capture and transcript presentation.
At run time the gateway also downloads the pinned voice runtime matched to your machine (CUDA on Windows, Metal on Apple Silicon, CPU on the other supported targets), plus the managed llama-server. You make no build-time choices for this.
The Workshop configuration
You configure the Workshop through a TOML file named workshop.toml. The application searches three places in order: beside the executable, the current directory, and ~/.promptforge/workshop.toml. The first file found wins. Every field is optional and the defaults are built in. With no file anywhere, the application keeps its state in ~/.promptforge/ and attaches to the gateway through its gateway discovery file. The application never writes the file, and the standalone server's workbench.toml fallback does not apply to it.
The keys you are most likely to set:
gateway.base_urlpoints the Workshop at a PromptForge gateway the gateway discovery file cannot see, such as one on another machine. When the value is empty, the Workshop attaches to a locally running gateway through its gateway discovery file or launches the siblingpromptforge-gateway. A Workshop-only install has no sibling, so with neither a running gateway nor an explicit value, startup fails with an error that names both remedies.gateway.api_keysupplies the bearer key for the gateway API. An empty key sends noAuthorizationheader, which is right for a gateway running with authentication disabled.server.bindis honored only by the standaloneworkshop-serverbinary. The desktop application owns its listener and always binds127.0.0.1on an OS-assigned port.server.state_dirchooses where the Workshop keeps persistent state. Agent session event logs live understate_dir/sessions/, and the per-profile model memory is written there. It defaults to the config file's own directory.agents.pathchooses which directory of.mdagent prompts is launchable. The default isagents/beside the config file. A missing directory offers no agents; that is a state, not an error.
String values support ${VAR} environment interpolation, so you can keep secrets out of the file. A literal dollar sign is written $$. An unset variable interpolates to the empty string instead of failing startup.
The configuration is strict about mistakes, so you find out about problems immediately. A config without a [gateway] section fails to load. Unknown keys or sections are a startup error, such as a leftover [voice] section from an older version. Error messages name the offending file, and a malformed ${...} interpolation gives a clear error. A browser launch failure, by contrast, is only logged as a warning; it never stops the server.
Working with your operating system
The Workshop is a desktop citizen, not just a web page in a frame.
You can drag files from your operating system and drop them into the application to attach them. You can open native file and folder picker dialogs from the Workshop. When you click a link to an external website, it opens in your system browser while the Workshop window stays on its own page. Links between pages served by the Workshop itself load inside the application window.
One protection is worth understanding early: a link to any other local server, even one on the same port spelled localhost or [::1], opens in the system browser. No other program on your machine gets the application's desktop features.
Safety and limits
The Workshop is built so that only you, on your own machine, can reach it.
The window loads its interface only from the local machine, never from a remote address. The Workshop refuses any request a browser marks as coming from another website, and it only answers requests addressed to a loopback host. Requests that change things must declare a JSON body. The live socket that carries chat only upgrades for the Workshop's own loopback origin or a native client.
Nothing hangs forever. A stalled request is answered with a timeout error instead of freezing: ordinary routes give up after 10 seconds, and routes that relay a call to the gateway allow up to 35 seconds so a stalled gateway surfaces as a meaningful failure. Live socket sessions are never cut off by a request deadline. A gateway that is down or wedged fails fast in the interface: connections give up after 5 seconds and ordinary requests after 30 seconds.
Startup also cleans up after previous runs. Leftover temporary files in the state directory are swept away on boot, so a crash during a previous save never leaves residue that affects the next launch.
You now know what the application is, how it starts, and what it connects to. The next chapter opens the window and walks through its regions.
The Workbench
You know how the Workshop starts and what its window is. This chapter teaches you how that window is organized: the regions it is divided into, the panels that live in those regions, and how to arrange them to fit the way you work. Everything you do in the Workshop happens inside a panel, so learning the layout once pays off in every later chapter.
The three zones
The Workshop window is a dock area divided into three named zones, rendered in the Cursor Dark visual theme:
- The left zone holds the workspace tree.
- The main zone holds document editors.
- The right zone holds the agent session.
Each kind of panel has a default zone it opens in until you move it. The Workshop tree opens on the left, editors open in the main zone, and the agent session opens on the right. On a fresh start you see two panels: the Workshop tree docked on the left, titled "Workshop", and the Agent Session panel docked on the right. The main zone stays empty until you open a document.
Below the dock area, a permanent full-width status bar runs along the bottom of the window. It is not part of the dock and is never saved as part of the layout.
The title bar
Across the top of the window sits a custom title bar. It shows the PromptForge program icon, carries the five application menus (File, Edit, Model, Window, Help), and leaves an empty center region you can grab. On Windows this bar replaces the native window frame; macOS and Linux keep their decorated windows. The bar is always shown, even when you run the Workshop in a plain browser, because it carries the application menus.
To operate the window from the title bar:
- Drag the empty center region with the primary mouse button to move the window.
- Double-click the same region to toggle between maximized and restored.
- Click the Minimize, Maximize, or Close control at the right end to operate the window.
The controls appear in the Windows-standard order: Minimize, Maximize, Close. The maximize control swaps its glyph and label between "Maximize" and "Restore" to match the window's current state, including changes made by Windows Snap or by drag-resizing. The window reopens at its previous size and position on the next launch. The native window controls appear only in the desktop application. In a plain browser the control cluster is hidden, because there is no native window for the commands to act on; the menus still work.
Zooming the interface
You can scale the whole interface to a comfortable size. Zoom applies uniformly to the whole window, so the chat, the editor, and every other surface scale together.
- Press Ctrl+= to zoom in one step. Ctrl+Shift+= also zooms in.
- Press Ctrl+- to zoom out one step.
- Press Ctrl+0 to reset to 100%.
Zoom changes in fixed steps of 10 percent, clamped between 50% and 200%. Your chosen level persists across sessions and is re-applied on every boot. A missing, corrupt, or out-of-range saved value leaves the default 100% in place. Zoom keeps working even when storage is blocked, such as in private mode; only the persistence is skipped. In a plain browser, zoom uses CSS zoom instead of native window zoom.
Panels
A panel is one unit of content in the dock: the Workshop tree, an editor, an agent session, or the Gateway Config panel. Every panel renders a normal chip tab, so tabs are always visible even when a panel is alone in its group.
A few rules govern how panels open:
- Reopening a panel that is already open brings it to focus instead of opening a duplicate.
- Each open document gets its own editor tab keyed by its file path. The same file never opens twice.
- Each agent session gets its own panel keyed by its instance id, so multiple agent sessions can be open side by side.
- Panel kinds other than editors and agent sessions are singletons. Only one of each can be open at a time.
Editor tabs are titled with the file's base name rather than its full path. Panel tabs update their displayed title when the panel's title changes. If an unknown panel is ever requested, you see a labelled placeholder instead of a broken dock.
You can close an Agent Session tab with the close button on the tab. Right-clicking an Agent Session tab opens a context menu with "Close" and "Close Others" actions.
- Press Ctrl+B to close the Workshop tree panel. Press Ctrl+B again to reopen it.
Rearranging the layout
The workbench is never locked. You can drag panels to rearrange the layout at any time.
When you move a panel to another zone, the application remembers that choice and reopens the panel in your chosen zone next time. Moving a panel back to its default zone clears the remembered override, so the panel follows its type's normal placement again.
Closing every panel in a zone collapses that zone. Opening a new panel into it rebuilds the zone on its own side of the dock. A rebuilt main zone regrows beside the left zone when possible, otherwise beside the right zone, so the layout keeps its familiar shape.
Layout persistence
The panel layout persists across sessions. Layout changes save automatically shortly after you move, resize, open, or close panels. There is no manual save step.
If the saved layout is missing, corrupt, or from an older version of the application, the Workshop discards it and boots the known-good default layout: the Workshop tree anchored left and the agent session open right. You can never lose the Workshop tree or the agent session. Both panels are restored on every boot even if a stale saved layout dropped them, and the Workshop tree's tab has no close button.
A few small behaviors keep the workbench predictable. Drag-and-drop of panels inside the application always works, because the application avoids registering an OS-level drop target that would break in-page dragging. The browser's native right-click context menu is suppressed inside the application, so right-clicks always produce Workshop menus.
Menus and Commands
You know the window's regions and panels. This chapter teaches you the command surface that sits on top of them: the five menus in the title bar, the keyboard shortcuts, and how menus behave. Once you know where the commands live, every later chapter can simply name a command and you will know where to find it.
The five menus
The title bar carries five menus: File, Edit, Model, Window, and Help. Click a menu button to open its popover. Here is what each menu holds.
The File menu:
- New Agent starts a fresh agent session; it opens or focuses the agent-session panel. New Agent is the only new-conversation command. There is no New Chat.
- Close Window closes the window, also with Alt+F4.
The Edit menu runs Undo, Redo, Cut, Copy, Paste, and Select All with the standard shortcuts Ctrl+Z, Ctrl+Y, Ctrl+X, Ctrl+C, Ctrl+V, and Ctrl+A. After an Edit command runs, focus returns to the field that had it.
The Window menu:
- Workshop Panel toggles the Workshop panel tree, also with Ctrl+B.
- Gateway Config opens or focuses the gateway configuration panel. It sits directly after Workshop Panel.
- New Agent opens or focuses the agent-session panel. It sits directly after Gateway Config.
- Zoom In, Zoom Out, and Reset Zoom zoom the interface, with shortcuts Ctrl+=, Ctrl+-, and Ctrl+0. Ctrl+Shift+= also zooms in.
- Minimize and Maximize/Restore operate the window. These menu commands do exactly what the visible title bar buttons do.
The Model menu lists every catalog model as a checkable radio row with the selected one checked. Each model's description appears as a tooltip on its row. When the catalog is empty, the Model menu shows a disabled "No models available" row. A Profiles section at the bottom of the Model menu selects the gateway profile; it appears whenever the gateway defines at least one profile, lists "No profile" first and then every profile, and checks the active one. The Models and Profiles chapter covers this menu in depth.
Help > About PromptForge opens the About dialog, which also shows the desktop update state. The Updates and Configuration chapter covers it.
Keyboard shortcuts
Beyond the menu shortcuts, the application binds a small fixed set of keys:
- Ctrl+S saves the active editor. The shortcut does nothing when no editor is active.
- Ctrl+W closes the active editor and prompts when there are unsaved changes.
- Ctrl+B toggles the Workshop tree panel open and closed.
- Ctrl+Tab cycles through the open editors and Ctrl+Shift+Tab cycles in reverse, wrapping around at the ends.
- Ctrl+Shift+F opens or activates the Workshop tree and moves keyboard focus into it.
The bindings are fixed. You cannot customize them, and there are no multi-key chords. Only plain Ctrl combinations are bound; combinations with Alt or Meta are left untouched. Unbound key combinations fall through to the browser and the editor, so typing, selection, clipboard, undo/redo, and in-file find keep their normal behavior. Inside the desktop application the browser's built-in shortcuts are disabled, so the application's own key handling never races them.
How menus behave
Menus in the Workshop follow the desktop conventions you already know, with a few details worth learning once.
Edit menu commands are enabled only when an editable element (a text input, textarea, or contenteditable element) holds focus. They act on the element that was focused before the menu opened. A disabled command cannot run and does not close the menu.
You can navigate open menus with the keyboard. ArrowDown and ArrowUp move between rows with wraparound. ArrowRight and ArrowLeft switch menus. Enter runs the focused row. Escape closes the menu and returns focus to its button. While any menu is open, hovering another menu button switches to it. Hovering alone opens nothing when no menu is open. An open menu closes when you click anywhere outside it or when the window loses focus.
Menu rows show the label on the left and the shortcut hint on the right in muted, smaller text. Disabled rows are muted and do not react to hover. Thin separator lines group related rows. Checkable rows keep a fixed-width check column so labels stay aligned.
The Model menu is live. It rebuilds its rows from the catalog every time it opens, and again whenever a workbench snapshot arrives while it stays open, so check marks move without reopening the menu. Clicking a model row sends the selection, and the check mark moves only when the server confirms the new selection. Keyboard focus survives a live rebuild of the open menu: focus stays on the equivalent row and falls back to the first row if the focused row disappears. While a profile selection is in progress, every Model menu row disables, and the target profile shows a pending "..." mark in place of its check until the server confirms. The still-active profile keeps its checkmark.
The same menus work in a plain browser. Only the native window commands (Minimize, Maximize/Restore, Close Window) do nothing there, because no desktop bridge carries them.
Context menus
Some panels, such as the Workshop tree, open a context menu of action items from a trigger element. Context menus share one set of behaviors:
- Activating the same trigger a second time closes the menu. At most one menu is open at a time.
- Items can carry an icon next to the label, a check mark for the selected choice, and a danger style for destructive actions.
- A right-click invocation opens the menu at the pointer position. The menu flips above the trigger or right-aligns when it would overflow the window.
- Escape dismisses the menu and returns focus to the trigger. ArrowUp, ArrowDown, Home, and End move through the items. Tab closes the menu.
- Activating an item runs its action and closes the menu immediately.
- The trigger announces its expanded state to assistive technology.
Panels and chat use one consistent set of small inline outline icons. The trash icon deletes an item, the folder-plus icon creates a folder, the microphone icon starts voice input, and the send icon sends the message. The icons are sized 15 or 16 pixels and drawn in the surrounding text color, so they stay legible across themes.
You can now reach every command the application offers. The next chapter teaches the status bar, which is how the application reports what it is doing while you work.
The Status Bar
You know the window, its panels, and its menus. This chapter teaches you the status bar, the permanent full-width footer at the bottom of the window. The status bar is how the Workshop tells you what it is doing whenever something takes noticeable time: startup phases, gateway round trips, dictation and transcription, and model downloads. Learning to read it means you always know whether the application is idle, working, or stuck, and why.
Reading the bar
The status bar shows a short label as its text. When startup finishes and nothing is happening, the resting state reads "Ready". Hover over the bar to see a longer description of the current status as a tooltip. Failures appear as errors, visually distinct from ordinary status updates: the text switches to red. Long status text truncates with an ellipsis instead of overflowing the bar, and numbers use fixed-width digits so values do not jitter as they change. The bar announces its updates to assistive technology.
During startup you see a "Connecting to gateway" update that names the gateway base URL being contacted. When startup finishes and nothing is happening, the bar returns to "Ready".
The right slot: progress bar and lights
The right end of the bar holds one of two things, never both at once. While an operation reports progress, a progress bar fills the slot. Otherwise the slot holds the indicator lights. The slot swaps as a unit.
When an activity can report how far along it is, you see determinate progress: units completed so far against units expected in total. A model download, for example, shows its label, the file name as the description, and a current-of-total count. Gateway-side work such as model downloads and profile switches renders on the Workshop status bar through the same progress display as local operations.
When no progress is showing, two small lights sit in the slot:
- The activity LED pulses green while output tokens arrive and amber while a model turn is thinking. It also tells gateway traffic (green) from dictation activity (amber). Green wins when both coincide. The thinking LED stays lit for the whole thinking period, not just a brief flash. Pulses fade in fast and decay slowly, so a stream of activity reads as one continuous glow.
- The recording LED lights up red while the microphone is recording.
Both LEDs sit dark when the application is idle. The recording LED sits one LED-width to the left of the activity LED. When a chat is aborted, the activity LED goes dark immediately, even though no final server status arrives for that chat. When an error status arrives, the activity LED goes dark at once and does not light again on its own.
Gateway connectivity
The status bar is where you watch the gateway connection. The Workshop probes the gateway's health endpoint and treats a transport failure, a slow answer, or a non-success status as unreachable. Each probe is bounded at 2 seconds. The Workshop opens and works normally whether or not the gateway has ever answered; only gateway calls wait.
- When the gateway stops answering, the bar announces "Gateway unreachable" with the explanation "the gateway does not answer its health probe". Calls to the gateway are not attempted while it is down.
- When the gateway returns, the bar announces "Connected to gateway". The model catalog refreshes by itself, because a gateway that was down may serve a different catalog.
You are notified only when reachability changes. A steady state never re-announces itself. While the gateway is reachable, the Workshop checks its health every 5 seconds, so a recovery is detected within about 5 seconds. While the gateway is down, retries use a jittered, escalating delay: starting at about 5 seconds, doubling per attempt, and never exceeding one minute. A gateway that accepts connections but never answers keeps the escalated schedule, because only useful work resets it. After roughly a full day of continuous outage, the Workshop stops probing and shows "Gateway reconnect stopped" with the advice "the reconnect budget is exhausted; restart the workshop to retry".
When a gateway call fails in transport, you see the gateway's own summary line as the error message. Every failure you hit surfaces as a short plain-language message near the status text. Production builds show no internal detail; debug builds append the underlying cause chain after the message.
Gateway progress appears on the status bar only while the gateway is reachable. When the gateway becomes unreachable the progress entry disappears instead of going stale. After a reconnect the progress resumes with a single fresh entry.
Live delivery and reconnection
The application holds one persistent live connection to the server. Status updates, the model catalog, and menu state arrive in the interface as they happen, with no manual refresh. The interface boots with its status bar, catalog, and menu state already populated; there are no loading round trips. Snapshots are pushed on every connect and resent on reconnect, and the newest status update is retained and replayed to late-connecting sessions, so if you reconnect you immediately see the current status. A late-joining session gets a status line recomputed from the current probe, not a stale retained announcement; if real work is in progress, such as a model download or a chat, that work's status frame replays as-is.
When the connection to the server drops, the status bar returns to a neutral "Reconnecting..." state. The application reconnects automatically: retries start at a one-second wait and double on each failure, capped at 30 seconds. The application connects over a secure socket automatically when the page is served over HTTPS, and a plain socket otherwise.
Locally-originated messages such as dictation errors appear in the status bar too, and are replaced by the next server status update.
Why the bar stays calm
The status bar is engineered not to flicker, so what you see is always meaningful:
- An operation that finishes in under one second never disturbs the status bar.
- Once the progress indicator appears, it stays visible for at least half a second.
- The bar never steps backward, even when a new operation starts while the previous bar is still on screen. Back-to-back operations share one continuous bar.
- When an operation has several sub-tasks, the bar shows a single weighted aggregate and the label names the sub-task that is still unfinished.
- Internal instrumentation never reaches the screen. Debug-level updates never change the status bar text or tooltip, though they still pulse the activity LED; only info and error severities are displayed.
- If updates arrive faster than the interface can draw them, the display skips ahead to the newest snapshot instead of lagging behind.
- Updates that arrive while the application is still starting are held and replayed in arrival order once the interface is ready. The holding queue is bounded at 32 pushes with the oldest dropped when full, and if the connection drops before the interface is ready, the queued messages are cleared.
You can now read everything the application tells you about its state. The next chapter teaches you to choose what the application runs: models and profiles.
Models and Profiles
You can read the status bar, so you can tell when the application is ready. This chapter teaches you to choose what the application runs: the model that answers your chats, and the profile that decides which models exist. By the end you will be able to pick a model, understand when chat is ready, and switch profiles with confidence.
The catalog
The Workshop does not invent its model list. The catalog comes from the configured gateway, which serves it at GET /v1/models. The Workshop relays the catalog verbatim, including upstream error bodies, so what you see matches the gateway's answer. Each model lists its id and owner, with an optional description. Each push replaces the previous list in full.
Every connected session receives each catalog update, so all open sessions show the same current list. A session that connects later receives the current catalog immediately. The catalog also refreshes automatically every time the gateway comes back after an outage, because a gateway that was down may serve a different catalog. A boot-time catalog failure heals itself this way. A failed, declined, or malformed catalog answer is logged and skipped rather than pushed, so your pickers never lose a usable list.
While the Workshop fetches the catalog, the status bar shows "Loading models...". When the gateway is known to be down, the request is refused immediately with the message "Gateway unreachable". A non-success answer shows "Gateway error:
Picking a model
You pick a model from the Model menu in the title bar. The menu lists every catalog model as a checkable radio row with the selected one checked, and each model's description appears as a tooltip on its row. When the catalog is empty, the menu shows a disabled "No models available" row.
The agent toolbar offers a second way to pick: a pill-shaped button that displays the id of the currently selected model. To use it:
- Click the pill button. A dropdown menu opens listing every model in the catalog.
- Click a model. It becomes the current model.
When no model is selected, the pill shows the label "Select model". When the catalog is empty, the dropdown shows a single inert "No models available" row. Hovering the button shows the current model's description as a tooltip.
One current model selection is shared by every Agent tab and the title-bar Model menu, so the chosen model stays consistent across the whole application. Your pick is sent to the server as a command, and the on-screen selection changes only when the server confirms it. The button label updates only after that confirmation, never optimistically on click. A catalog refresh never silently changes which model is selected, and selection indicators update only on a real change, so the Model menu and Agent tabs do not flicker when the server re-confirms the same model. Picking an unknown model id is refused with an error message, and the previous selection stays in place.
If a refreshed catalog no longer contains the selected model, the Model menu clears the selection and chat becomes unavailable until you pick again.
When chat is ready
Chat input is enabled only when all of these hold: the catalog has models, a model is selected, no profile switch is in flight, and the gateway is reachable. The server computes this readiness; the interface never derives it.
On startup and after every reconnect, the application restores the remembered model for the active profile, falling back to the first catalog model when the remembered one is gone. A fresh boot against a live gateway lands ready to chat with no manual pick. While the gateway is unreachable, chat input stays disabled even with a model selected. Your chosen model survives the outage; only chat readiness flips, and the selection is still in place when the gateway returns.
If a model selection cannot be sent because the connection is down, the status bar shows an error naming the model and the cause: "Could not select
Profiles
A profile is a named checklist on the gateway that decides which local and speech models it loads at boot. Remote models are always available; the profile governs what runs on the gateway's own machine. The Workshop shows you the list of profiles the gateway offers and which profile is currently active, read from the gateway. You can see the Model menu's full state at a glance: every profile, the active profile, any profile selection in progress, and the selected model. A gateway without profile support shows an empty profile list instead of an error or stale names.
The gateway loads its local models once, when it starts, so changing the profile means restarting the gateway. When the gateway is a sidecar the Workshop launched and supervises, the Workshop performs that restart for you. To select a profile:
- Open the Model menu.
- Find the Profiles section at the bottom. It appears whenever the gateway defines at least one profile. "No profile" is the first entry, and the active profile is checked.
- Click the profile you want, or "No profile" to run remote models only.
The selection climbs a ladder of up to three labeled stages shown in order with determinate counts: "Selecting profile..." (1 of 3), "Restarting gateway..." (2 of 3), "Loading models..." (3 of 3). The status bar names the profile being selected while progress is shown. The first stage persists the selection on the gateway. When the gateway is already running the chosen profile, the ladder stops there and the menu settles at once. Otherwise, for a supervised sidecar, the Workshop asks the gateway to shut down and waits up to 90 seconds for its relaunched replacement to come up serving the chosen profile; the replacement's boot then loads the profile's models, which can take minutes while weights load into VRAM.
When the gateway is one you configured on another machine, the Workshop never stops it. The selection persists on that gateway and the status bar reads "Profile selected" with a notice that you must restart the gateway by hand to load it; the running profile stays active until you do.
While a selection runs, the menu shows a pending state and chat input is disabled. Only one selection runs at a time; starting a second while one is in flight is refused with an error.
When a selection completes, the application selects the model last used on that profile, or the first catalog model when none is remembered. Chat becomes ready again and the status bar returns to idle. When a selection fails, you see a "Profile switch failed" notification carrying the gateway's own error message; if the gateway still serves, the selected model and chat readiness are restored. A sidecar that was shut down and did not return in time reports "gateway did not return after restart", and the Workshop's supervisor keeps looking for it and repopulates the menu when it appears. After any selection that leaves a gateway serving, the profile list and model catalog are refreshed, so the menu reflects the gateway's real state. If the connection is down when you try to select, a local error appears on the status bar: "Could not switch to
The application remembers the selected model per profile and restores it across restarts. The memory lives in a workshop-state.json file in the server's state directory. A missing, unreadable, or corrupt memory file never blocks startup; the application starts with no memory and selects the first catalog model.
The model cache
You can trigger a download of a model blob into the gateway's cache and watch cumulative progress until the blob is ready or the download fails. When the requested blob is already cached, you get an immediate ready answer instead of a download. The cache feature is meaningful only in the standard local deployment, where the Workshop and the gateway run on the same machine and share the filesystem.
Before the application receives its first state from the server, you see an empty workbench: no profiles, no active profile, no selected model, and chat gated off. Every server push refreshes the Model menu and chat gating, even when nothing changed, so the display never goes stale.
You now have a model selected and chat ready. The next chapter teaches the chat surface itself.
The Chat Surface
You have a model selected and chat is ready. This chapter teaches you the chat surface itself: how to send a prompt, how to read the transcript, and how to steer a session once it is running. Chat is the heart of the Workshop, and everything here builds directly on the Models and Profiles chapter.
Your first message
The Agent Session panel on the right side of the window is where you talk to the selected model. Chat always runs as a live agent session, not a one-shot buffered request. Every reply streams through the open session, which opens instantly and stays open for the whole session.
The default chat is a transparent pass-through with no added system prompt and no tools. Your messages go to the model currently selected in the interface. A fresh install always offers this working built-in chat agent, even when there is no agents directory at all. Later you can add your own agents by dropping .md prompt files into the agents directory; each file appears as a launchable agent under its file-stem name in a sorted list, and a newly added agent file shows up in the agent list on the next connect, without a restart. Placing a chat.md file in the agents directory shadows the built-in one, so you can replace the default chat with your own prompt. An existing chat.md that cannot be read surfaces its error instead of silently serving the embedded source.
To send your first message:
- Click into the input box at the bottom of the Agent Session panel. The placeholder reads "Plan, Build, / for skills, @ for context".
- Type your message.
- Press Enter.
Enter sends the prompt. Shift+Enter inserts a newline without sending. If you use a CJK input method, an Enter that commits an IME composition never sends, so you can confirm candidates safely.
Sending delivers exactly the text you typed, never trimmed. An empty box sends nothing. A failed send keeps the text for retry. A successful send clears the box. The box grows and shrinks with what you type, within a minimum and maximum height (about 36px to 200px), and scrolls past the maximum.
The prompt box and send button enable only while the agent is asking for input. Otherwise the box is read-only and send is disabled.
A push-to-talk microphone button sits beside the send button. It stays visible in every state, and when dictation cannot start, a click names the blocker on the status bar. The Voice Input chapter covers dictation.
Reading the transcript
The session reads as a scrolling feed of rows, one row per transcript entry, with each kind of entry styled distinctly. The feed scrolls itself to the newest entry whenever it repaints. New rows are announced to assistive technology as they arrive; settled history is never rebuilt or re-announced during streaming.
Your own messages appear under a muted "You" label as plain text, right-aligned as bubbles. Text you send is never interpreted as markup, so pasted or typed HTML cannot inject formatting or scripts.
Agent replies render as formatted Markdown with a muted line above naming the model that produced the reply. Replies and reasoning that are still streaming carry a visible pending style and a blinking caret at the live tail. While a reply streams, you see the answer text arrive chunk by chunk. The status bar shows "Running agent turn" while the agent thinks, "Streaming response..." while the reply streams, and "Ready" when the turn completes. The model's reasoning streams live on its own side channel, separate from the answer text, and appears in a collapsible block titled "Reasoning" or "Reasoning (model)". It stays open while it streams and collapses once it settles.
Tool calls appear as collapsible cards with a clickable header. The header shows the tool's name (or a generic "Tool call" / "Tool calls" label), a count badge for multi-call batches, and a status dot. A card opens on its own while the call runs and closes when the result arrives. A card you opened by hand stays open. Each call's arguments render as syntax-highlighted JSON. The result appears as a preformatted block labeled with the id of the call it answers. A batch that cannot be parsed still renders as raw text instead of vanishing.
Errors appear inline in the transcript with a visible "Error: " label, never by color alone. A message that could not be sent because the connection is down appears as a local notice: "The message was not sent: the agent socket is down."
You can observe per-reply model metrics such as token usage and generation speed attached to the assistant's replies. The log records which model produced each entry, per-reply token usage (prompt, completion, cached, and reasoning tokens), and per-reply timings (time to first token, generation speed in tokens per second, and end-to-end latency).
Mentions and the composer extras
You can mention files with @ and pick them from a typeahead popup that opens next to the cursor. The list filters its entries by case-insensitive substring match against the text typed after the @. While the popup is open, ArrowUp and ArrowDown move the highlight through the suggestion list with wraparound, and Enter inserts the highlighted item instead of sending the message. Clicking a row inserts that file without moving focus out of the editor. Escape dismisses the popup. A query with no matches hides the popup.
Each referenced file appears as an inline pill inside the prompt editor, with a file icon and the file's label. The pill behaves as a single unit, not editable text. Clicking the X button on the pill removes the whole mention. The suggestion list currently offers three canned file entries (README.md, src/main.ts, Cargo.toml) as a stand-in until the workspace file index exists.
The agent toolbar
A toolbar above the input bar groups the mode chip, the model picker, and a context-usage ring in one row.
The mode chip lets you choose among five agent interaction modes: Agent, Plan, Debug, Multitask, and Ask. The chip starts in Agent mode. Click it and pick a mode; the chip's icon and label update immediately and the change is announced to the rest of the application. Re-picking the current mode produces no change and no event.
The context ring is a small 16px gauge showing how much of the model's context window the current session has used. The arc fills in proportion to the percentage used. The ring reads 0 percent until real usage data exists, and readings are clamped between 0 and 100. Assistive technology hears it announced as "Context usage" with the current percentage.
The model picker in the toolbar is the pill button from the Models and Profiles chapter; it shares the same selection as the title-bar Model menu.
Sessions that survive
A session is more durable than its connection. Agent sessions survive a dropped connection. The socket reconnects on its own and reattaches to the same session. The server replays the persisted event log from the beginning, and a per-client cursor drops duplicates, so you see each event exactly once and in order. Every unanswered prompt is re-announced in the order it was asked.
You can also attach to an already running session by its session id, resuming where that session stands. Sessions outlive sockets.
Your run history is recorded as a durable event log that survives restarts. Each session's conversation persists to a JSONL transcript file named after the session id under the sessions state directory. The log format is versioned, so session logs saved on disk keep loading after every application update. A damaged, truncated, or incompatible history file is refused with a clear error instead of showing a wrong or partial history. You can return to a previous run and continue it: the saved history is restored with its original ordering, and new events append to the same record. If saving the log to disk fails, the run keeps working and nothing you see is lost; the failure is logged as a warning and saving retries on later events.
The chat shows both sides of the conversation back to the model each turn, rebuilding the message list from the recorded user and agent messages. The conversation accumulates turn over turn, and what you typed reaches the model byte-exact, with newlines, quotes, and unicode preserved. Selecting another model takes effect on the next turn, and each reply is attributed to the model that produced it. A relaunch over retained or reloaded history resumes the conversation exactly where it stood.
Cancelling and failing gracefully
You can cancel a running turn. Cancellation is a stop reason, never an error. Pending prompts close as cancelled, and the relaunched agent returns to waiting over its retained history. The chat is immediately usable again.
The chat survives a transport failure: the session surfaces the failure and returns to waiting for the next message. When a single model round fails, you see an error message naming the agent; the agent survives the failure and returns to waiting for input. When a run fails outright, you see an "Agent failed" notification carrying the error text. If stream chunks are dropped on a slow connection, the completed transcript event repairs the text. Late chunks that arrive after a cancel are discarded, so you never see duplicate or orphaned streaming text.
Closing a session ends the agent run for good with no relaunch. The saved transcript stays on disk.
When the agent asks you a question
Some agent programs pause and ask for input. When an agent program needs input, the Workshop presents a prompt in the session's input box and waits for you to type an answer. The input box stays pinned to that request until it is answered. Each prompt accepts exactly one answer, and your typed answer reaches the agent byte-exact as typed, preserving newlines, quotes, braces, backslashes, and non-ASCII characters.
Cancelling a turn while a prompt is pending dismisses that prompt, so the input box is never left stuck on a dead question. A prompt that dies unresolved is explicitly cancelled on screen, never silently abandoned. A pending prompt survives a lost connection: on reconnect, every unanswered prompt is shown again in the order it was asked, and a stale prompt vanishes. You can answer a prompt that was asked while the socket was down; the answer is delivered normally once the session is back.
The agent panel
You work with one agent session per panel. Opening a new panel starts a fresh session. Closing the panel ends the session and releases its connection. The panel automatically launches the "chat" agent when the server reports available agents, falling back to the first available agent when "chat" is not present. You can open additional agent sessions from the Agents menu (New Agent) or the Workshop menu (Open Agent Session). Each new session gets its own panel in the right zone. Agent windows are modal: one window serves one session at a time, and trying to open a second session in the same window is refused with an explanation.
While the panel has no active session, you see a launchable-agent menu labeled "Agents" for assistive technology, with the lead line "Launch an agent to start a session." There is one button per discovered agent, labeled with the agent's name; clicking it launches a session. When no agents are discovered, you see the message "No agents discovered." After you launch an agent, every launch button disables until the server answers, preventing a double launch. A refused launch shows the server's error message and re-enables the buttons for another try. When the agent socket is down, you see "The agent socket is down; it reconnects by itself. Try again shortly." and no launch is sent. The whole menu disappears once the session acknowledgment arrives, replaced by the session surface. Starting or reattaching to a session clears any pending input prompt; a same-session reattach keeps the transcript, and a new session starts the transcript fresh.
What chat content can contain
Model-authored chat content renders as Markdown: headings, bold, italic, inline code, lists, blockquotes, tables, links, and images. Fenced code blocks are syntax-highlighted in the application's dark theme in twelve languages: bash, css, html, javascript, json, lua, markdown, python, rust, toml, typescript, and yaml. A code block in an unrecognized language renders as a plain code block, and if highlighting fails to initialize, code blocks still render as plain preformatted text.
You can size an image embedded in chat content by appending a =WxH or =Wx dimension suffix to the image source. Links show a tooltip on hover that defaults to the link URL.
Model-authored markup is sanitized before display. Scripts, inline event handlers, and dangerous URLs such as javascript: links are stripped. Tool results render as plain text, so markup inside a result can never execute.
Launching an agent is refused when the gateway settings cannot produce a usable model client. The error tells you to check gateway.base_url and gateway.api_key in workshop.toml. The rest of the Workshop keeps serving.
You can now hold a full conversation, steer it, and recover from anything that interrupts it. The next chapter teaches you to speak your prompts instead of typing them.
Voice Input
You can type prompts into the chat surface. This chapter teaches you to speak them instead. Dictation uses a push-to-talk microphone button beside the send button, and the transcript lands in the prompt exactly as if you had typed it. If voice is not available on your machine, this chapter also teaches you how to tell and why.
The desktop application keeps the microphone connection same-origin: its Workshop server relays /v1/realtime to the gateway's fixed /v1/realtime?intent=transcription target. The relay authenticates upstream but never parses speech payloads or owns speech state. The gateway credential stays in the server process and is never exposed to the webview.
Dictating a prompt
To dictate into the chat input:
- Click the microphone button beside the send button. Its tooltip reads "Push to talk".
- Speak your message.
- Click the microphone button again to stop. The tooltip now reads "Stop recording".
While you speak, you see one evolving transcript. Each revision replaces the previous hypothesis in the same editor range, so revised phrases do not accumulate. One continuous recording remains one item and one take for arbitrary duration, with one commit when you stop and one authoritative completion. The gateway compacts finalized audio while retaining at most 30 seconds of resident, queued, and actively decoding PCM, so recording duration is not capped at 30 seconds. When you stop, the authoritative completion replaces the hypothesis and focus returns to the input. After you stop, the input stays locked until the final transcript arrives; other committed takes may finish independently.
Dictation splices the transcript into the current selection, behaving like typing at the cursor. Newlines in the transcript become line breaks. Dictating over a selection replaces the selection outright. Consecutive takes compose, because each take captures the cursor position fresh at record start. You never see stale transcription text from a previous take: takes are numbered per connection, and frames from a superseded take are discarded.
While a take records, the input locks against typing and shows a recording ring, so the insertion geometry cannot be disturbed. You can still press Enter to send what the box shows. Sending during a take sends the visible text, interim transcript included, and discards the take. Discarding a live take, for example by closing the tab or starting a new session, restores the pre-take text and unlocks the input. An empty take tells you no speech was detected, with the number of captured audio frames.
The status bar shows a red recording LED while the microphone is capturing, and the mic button shows a solid danger-colored fill with a matching ring while recording.
When the mic does nothing
The mic stays visible and clickable in every state. Dictation is gated by the agent's pending input wait. Clicking it at another time names the blocker on the status bar: "The agent isn't asking for input; the mic opens when it does." The first eligible click may connect the Realtime session and ask you to try again in a moment; the session then reconnects with bounded backoff after a dropped connection.
Failures during dictation are named too. Microphone permission denial or capture failure is named on the status bar. A dropped dictation connection is reported on the status bar, including drops before the final transcript lands. Arbitrary-duration capture requires the accurate transcription worker to keep pace on average. If it falls behind until all 30 retained seconds are owned, Workshop stops capture, preserves the already accepted visible transcript, flushes the microphone, and commits the still-valid input without clearing or rolling it back. Other server errors retain the ordinary failure behavior and restore the pre-take text. A browser without microphone, audio, or WebSocket support is told "Dictation is not available in this browser."
Under the hood, the Workshop serves a payload-opaque Realtime socket at /v1/realtime. Browser capture applies echo cancellation and noise suppression, resamples to 24 kHz, converts samples to signed little-endian PCM16, and sends canonical Base64 audio appends. Stop flushes the capture worklet before committing the input buffer, so the final short block is included.
Microphone permission on each platform
Each platform handles the microphone grant differently:
- On Windows, the application grants the microphone permission automatically. You are never interrupted by a microphone permission prompt. Every other permission kind keeps the normal browser behavior.
- On Linux, the application turns on media capture in its webview and grants microphone and camera capture requests automatically. Other permission requests, such as notifications and geolocation, remain denied by default.
- On macOS, the application holds the audio-input entitlement that permits microphone capture for local dictation. The system permission prompt explains: "PromptForge uses the microphone you select for local voice dictation."
If microphone setup fails at startup, you can keep working in the application and only voice input stays unavailable.
Voice configuration
Voice input comes pre-tuned with a 15-second transcription window and a 500 ms interval, set in the [stt] section of the gateway boot config:
[stt]
window_seconds = 15
interval_ms = 500
You can add a vocabulary list of domain terms to bias recognition:
vocabulary = ["MCP", "GGUF", "Lua"]
Version 2 accepts only the canonical [stt] section. Legacy [workshop.stt] input is rejected as an unknown workshop field whether it appears alone or beside [stt], and the gateway saves only [stt].
First run provisions two recommended speech-to-text models: whisper-base-en for interim results and whisper-small-en for final results. They download from Hugging Face with pinned sha256 checksums and stated VRAM requirements of 1.0 GB and 2.0 GB. The generated configuration boots the gateway into a profile named default that activates both provisioned whisper models.
You can now speak or type your prompts. The next chapter teaches you to give the agent files to work on by granting folders to the workspace.
The Workspace
You can converse with an agent. This chapter teaches you to give the agent files to work on. The Workshop never roams your disk on its own: you grant it access to specific folders, and the Workshop tree panel on the left shows you exactly what you have granted. By the end you will know how to grant folders, browse them, and take access away.
Granting a folder
The fastest way to grant a folder is drag and drop. In the desktop application, drop a folder onto the window and it becomes a workspace root. Dropping a single file grants the application access to the file's parent folder instead of just the file. On Windows you can drop files or folders straight from Explorer, and the application receives the real OS paths of the dropped items. Each successfully dropped path is confirmed on the status bar with a message naming the path. When one dropped path cannot be opened, the status bar shows an error for that path and the remaining dropped paths are still added.
- Dropping a file onto the window never by itself gives the application access to the file's bytes. The page grants each dropped path through the workspace API first.
- Dropping files onto the window never navigates the page away from your session. In-page drags such as panel tab drags keep their normal behavior; only drags carrying OS files are intercepted.
You can also add a folder without dragging. Click the header "+" button labeled "Add Folder to Workspace...", or right-click empty space in the panel and choose the same item. In the desktop application you pick a folder through the native folder picker. In a plain browser you type the path into an "Add Folder to Workspace" dialog. The drop-to-grant feature is desktop only; in a plain browser, dropping files keeps the normal HTML drag/drop behavior of reading file contents and never grants workspace access.
The outcome of adding or removing a folder is always announced on the status bar, as a success or an error. Grants registered through any session are visible to every open session immediately, and open panels such as the Workshop tree refresh automatically to show new grants.
Folder grants last only for the current session. They are held in memory and are not saved to the profile.
Browsing the tree
The Workshop tree lists the granted workspace roots and browses one directory at a time. When no folder is selected, the panel shows the granted folders as the top level of the tree. When no folders are granted, you see the hint "Drop a folder onto the window to browse it here."
Each granted folder row shows the folder's own name rather than the full path, with the full path available as the row tooltip. A drive root shows its path. Directory listings show folders before files, each group sorted alphabetically by name. Each entry carries its name, full path, kind (directory or file), byte size, and modification time. Browsing is paths only: the tree lists names and never reads file contents.
To browse:
- Click a directory's chevron to expand it. Click again to collapse it.
- Click a file to open it in the editor zone. The Editor chapter covers what happens next.
Your expansion state and fetched listings persist for the session. Closing and reopening the Workshop panel restores the tree as it was left. A directory load failure appears as an error row inside the affected list, exposed to assistive technology as an alert. Pressing Ctrl+Shift+F activates the file tree and moves keyboard focus into it, even while the tree is empty.
A granted folder that has been deleted from disk still appears in the panel, flagged as missing so you can clean it up: a struck-through name in the danger color plus a "missing" text label.
Confined access
The grant boundary is enforced, not cosmetic. You cannot open, list, or save any path outside the granted folders; the application refuses with a "path is outside every granted root" error.
The refusal messages are precise about what went wrong:
- Paths containing
..are refused before any disk access, however they were encoded, with "path contains a forbidden component". On Windows, file names containing a colon are refused. - A path that is not a regular file fails with "path is not a file".
- A tree listing for something that is not a directory fails with "path is not a directory".
- A missing path reports "path does not exist".
Nested grants are independent. Revoking a parent folder's grant leaves a separately granted child intact, and files under the child stay reachable.
Dropped paths keep their native spelling, including backslashes, spaces, and Unicode characters. Any Windows verbatim prefix is removed. On older WebView2 runtimes, Explorer drops degrade gracefully instead of failing the application.
Revoking a grant
To take access away:
- Right-click the root row of the granted folder.
- Choose "Remove from Workspace".
Files under the removed folder lose access on their next operation. Removing an unknown root reports "path is not a granted root". A root deleted from disk stays removable, so you can always clean up a missing entry.
You can now grant folders and browse them. The next chapter teaches the editor, where you open and change the files those folders contain.
The Editor
You have granted folders and you can browse them in the Workshop tree. This chapter teaches you to open the files those folders contain, edit them, and save them safely. The editor is where reading the agent's work and making your own changes happen, and it is built so you never lose text or silently overwrite someone else's.
Opening a file
To open a file, click it in the Workshop tree. The file opens in its own tabbed editor panel in the main zone, with one panel per file. The tab title shows the file's base name rather than its full path.
You can open a text file from a granted folder and see its full contents, up to a 1 MiB size limit. The editor targets source text, not media. A larger read fails with an error that states the byte limit. Binary files cannot be edited; the attempt is rejected with "file is binary, not text". Files that are not valid UTF-8 are rejected with "file is not utf-8 text".
The editing surface is a CodeMirror-based text editor. Syntax highlighting is chosen automatically from the file extension: JavaScript, TypeScript, JSX, TSX, Python, Rust, JSON, Markdown, YAML, and TOML. Files with unknown or missing extensions open as plain text with no highlighting mode. You can search within the open document using the editor's built-in search panel, styled to match the application's dark theme.
Editing and saving
Edit the text as you would in any code editor. A dot marker appears in the tab title when the document has unsaved changes, and clears when the document is clean again.
To save the active editor, press Ctrl+S. The shortcut does nothing when no editor is active. To close the active editor, press Ctrl+W; a clean panel closes immediately. To move between open editors, press Ctrl+Tab to cycle forward and Ctrl+Shift+Tab to cycle in reverse, wrapping around at the ends.
You can create a new file inside a granted folder by saving to a path that does not exist yet.
Saves are atomic. You never see a half-written file or a leftover temporary file after a save. A crash or power loss during a save leaves either the old contents or the new, never a truncation. You also never lose unsaved typing to a slow save: edits made while a save write is still in flight remain marked as unsaved after the save completes. Triggering a second save while one is in flight does nothing, so you cannot stack overlapping writes.
Load and save failures appear as an alert bar above the editor. The newest error replaces the previous one. The editor also warns when a panel opens with no file path.
Conflicts
When you save a file that changed on disk since it was read, the save is refused with a conflict instead of silently overwriting. Each save carries the version token from the previous successful write, so the editor never silently overwrites a file that changed elsewhere. You get a "File changed on disk" dialog with two choices:
- Reload discards the editor's text and loads the on-disk text.
- Overwrite writes your changes over the file on disk, re-reading the fresh token first so the write succeeds.
Closing with unsaved changes
Closing a panel with unsaved changes opens an "Unsaved changes" dialog with three choices:
- Save writes the file and closes the panel.
- Discard abandons your changes and closes the panel.
- Cancel returns you to the editor.
A failed or conflicted save leaves the panel open. The panel closes only after a successful write.
Dialogs and read-only mode
Modal prompts, such as the editor's conflict and close prompts and the tree's Add Folder prompt, appear as a themed dialog box overlaid on the panel you are working in, dimming the rest of that panel. Dialog behavior is consistent across panels:
- You read a title and a message line at the top of each prompt.
- Prompts can show a labeled single-line text field.
- When a dialog opens, focus moves into it, landing in the text field or on the first button.
- Destructive actions are styled as danger buttons.
- Value-dependent buttons stay disabled until you type something.
- Enter inside the text field submits the dialog through its primary button.
- Escape dismisses the dialog without taking any action.
- Tab and Shift+Tab cycle focus within the dialog's controls and cannot escape to the panel behind it.
- When the dialog closes, focus returns to the element that had focus before the dialog opened.
- Re-invoking an already-open dialog does nothing.
You can toggle the editor between editable and read-only without losing the document, the undo history, or the view state. When the workspace reloads a file from the server, the reload lands in place as one marked transaction instead of an editor rebuild: you keep undo history, selection, and scroll position, and you can undo back across the reload. A reloaded file arrives clean and is not flagged as an unsaved change.
You can now open, edit, and save workspace files with confidence. The final chapter teaches you to keep the application current and tuned: updates, the About dialog, and the Gateway Config panel.
Updates and Configuration
You can operate the whole application: the window, the panels, the menus, the status bar, models, chat, voice, the workspace, and the editor. This final chapter teaches you to keep the Workshop current and tuned: the update flow, the About dialog, and the embedded Gateway Config panel.
Keeping the Workshop up to date
The installed application automatically checks the latest GitHub Release shortly after startup and installs only cryptographically verified updates. Downloaded updates are verified against a pinned public key before installation, so tampered updates are rejected. The automatic check runs on the desktop application only, and update checks give up after 30 seconds rather than hanging. On Windows, updates install passively, applying with minimal interruption to your session.
Platform notes:
- On Linux the update flow is available only when running as an AppImage. Package-managed installations show the update flow as unsupported and never contact the update endpoint.
- In a plain browser session the update flow stays inert.
- Nightly builds do not produce updater artifacts, so a nightly install does not receive automatic in-app updates.
When an update is available, you see a banner floating at the bottom-right corner of the window, above the status bar. The banner shows the new version number and a one-line summary of the release notes. You have two choices:
- Click "Remind me later" to dismiss the banner and bring the prompt back later.
- Click "Update now" to start the update immediately.
While an update downloads, installs, or restarts, a full-screen modal overlay takes over the window. You watch download progress as a percentage and a progress bar, with bytes received against the total size. After the download finishes, the application installs the update and restarts itself.
When an update download or install fails, you see the failure reason and can dismiss the overlay with a Close button to return to the application. You can expand an "Update log" section in the overlay to read the raw log lines produced during the update. When the application is already up to date, the update state reports that no update is available. When an update check fails, you see an error message.
The About dialog
Open Help > About PromptForge to see the About dialog. It names the product, the application version, and the license, shown as "License: BSL-1.0". A development build shows the version "dev" instead of a release number.
The About dialog is also where you trigger an update check manually. The update button reflects the state:
- "Desktop updates unavailable" in a browser.
- "Updates are managed by your package manager" on package-managed installs.
- "Checking for updates..." while a check runs.
- "Show update
" when an update is ready. - "Retry update check" after a failed check.
The About dialog traps keyboard focus: Tab and Shift+Tab cycle between its buttons and never leave the modal. You can dismiss it with the Escape key or the Close button, and focus returns to the element that opened it. Only one About dialog can be open at a time.
The Gateway Config panel
You can view and change gateway configuration without leaving the Workshop, in the Gateway Config panel. The panel opens in the main zone through the application's Gateway Config command, titled "Gateway Config". Opening it a second time focuses the existing panel instead of opening a duplicate, and you can close it from its tab's close action.
The panel embeds the gateway's configuration web interface, served same-origin through the Workshop at the /gateway/config/ route in panel mode. It opens in the dark theme on the local gateway view. From the panel you can:
- View the gateway's current configuration.
- Edit and save gateway configuration and environment values.
- Apply or revert pending configuration changes, and see whether the configuration has unsaved edits or changes waiting to be applied.
- Search and browse Hugging Face models.
- View gateway status, system information, model information, chat templates, environment, and orphaned files.
- View the downloaded model cache and delete a cached model to free disk space.
- Trigger the gateway's reveal action.
Panel actions are announced on the Workshop status bar: "Gateway configuration applied", "Gateway configuration changes reverted", and "Gateway download started". Long-running panel operations such as cache downloads can stream for minutes without being cut off by a timeout. When the gateway is unreachable, the panel reports the failure instead of hanging.
You never handle the gateway access key. The Workshop server attaches the bearer key on the server side of every forwarded panel request. Neither the Workshop page nor the embedded config panel ever sees it, and the key is never written to logs. The panel's API requests go through an allowlisted proxy; anything outside the configuration surface is refused, including chat completions, progress subscriptions, health checks, and direct cache uploads. Deleting a cached model is allowed only by its 64-character lowercase hex digest. Requests with malformed or absolute targets are refused locally with a forbidden status before anything leaves the application. The panel is reachable only from your own machine, never from the local network, and the embedded configuration interface runs in a restricted sandbox limited to running scripts within the same origin.
Reskinning the interface
If you build the Workshop from source, you can reskin the entire interface by editing CSS custom properties in the :root block of ui/style.css. Every color, spacing step, radius, font, scrollbar metric, and the status bar's LED and progress effect is a custom property there. To reskin without editing the shipped stylesheet, add a <link> after /style.css in ui/index.html and redeclare any variable on :root. Later declarations win the cascade. Focus on menus and controls is shown through state backgrounds, opacity, or underlines, never through outline rings or focus boxes.
You have completed the tour. You can install and start the Workshop, read its window and status bar, pick models and switch profiles, converse with an agent by keyboard or voice, grant folders, edit files, and keep the application current and configured.
The Gateway
- Install and Run
- The Configuration File
- Remote Models and Endpoints
- Local Models
- Speech-to-Text
- Speech Synthesis
- Profiles and Selection
- Dominions and Queues
- Editing Configuration Safely
- The Configuration UI
- Serving and Observing
Install and Run
This chapter teaches you to get the gateway running on your machine: how to install it, how to start it with a config file and a profile, and how to confirm it is healthy. You do these things every time you bring the gateway up, so they are worth learning well.
Install the binary
The gateway is a single binary named promptforge-gateway. It serves an OpenAI-shaped inference API. Install it with cargo:
cargo install gateway
Confirm the install by printing the version:
promptforge-gateway --version
Start the gateway
Start the gateway by naming a config file and a profile:
promptforge-gateway --config gateway.toml --profile main
The --config flag gives the path to the config file. The --profile flag names the profile to activate. The gateway always starts from one config file and one active profile.
Startup is bind-first. The gateway opens its listener and answers health, status, progress, configuration, and every ready route immediately, then provisions models afterward as one queued boot command. Downloads, local model spawns, and the speech engine load all run inside that command while the gateway is already serving, and you can watch it on the status and progress endpoints. A configured model that is still loading answers 503 with the code model_loading until its provisioning finishes.
You can supply both values through environment variables instead of command-line arguments. The config path comes from --config or from PROMPTFORGE_GATEWAY_CONFIG; the flag wins when both are set. The profile comes from --profile, then PROMPTFORGE_PROFILE, then the sibling state file the gateway keeps beside the config.
You can also start the gateway with no config file at all. When no gateway.toml exists beside the executable, in the working directory, or in the user profile's .promptforge directory, the first run writes a default config there - loopback-only on an OS-assigned port, with a fresh random bearer key and trust_loopback = true so callers on the same machine need no key - and boots from it. The generated file notes the caveat beside that line: on a shared machine any other OS account can then use the gateway, and trust_loopback = false requires the key from everyone. The generated config selects a profile named default, so a bare first boot needs no flags.
The system tray
On a desktop system the gateway's face is the system tray. The icon shows the gateway's state, and its menu carries a status line, a Workshop item that launches the Workshop application when the installer laid it beside the gateway, a Settings item that opens the configuration UI in your browser, a Launch at Login toggle, and Quit. A gateway started at login never opens a browser or a window.
For servers and CI, --no-tray keeps the plain headless loop. In a tray-less environment, --print-url prints the Settings URL to stdout once the gateway is bound. --browser opens the Settings page in your default browser once bound; the installer uses it on a Gateway-only install's first run. Launching promptforge-gateway while one is already running never starts a second copy: it opens the running gateway's Settings page instead.
After every successful bind the gateway writes a gateway discovery file (gateway.json in the run directory under the state directory) carrying its port, bearer key, and process id. PromptForge components read that file to attach to the running gateway instead of starting a second one, and a clean shutdown removes it.
Check that it is healthy
Once the gateway is serving, probe its health endpoint:
curl http://127.0.0.1:8081/health
GET /health needs no credentials. It always answers 200 while the gateway is serving.
Every /v1 route is authenticated with the shared bearer key from the config file. The address in this request is the bind value from the [server] section of the config file; 127.0.0.1:8081 is an example bind. A request with a wrong token is rejected with status 401 and error code unauthorized, from any peer:
curl -H "Authorization: Bearer wrong-token" http://127.0.0.1:8081/v1/models
From the gateway's own machine you can leave the key out entirely. With the default trust_loopback = true, a loopback request that presents no credential is admitted:
curl http://127.0.0.1:8081/v1/models
This convenience has one cost: on a shared machine, any other OS account can use the gateway the same way, including reading upstream API keys from the admin config surface. Set trust_loopback = false in [server] to require the key from every caller. The configuration chapter covers the rule in full.
Choose what to build
Build-time feature flags decide which capabilities exist in the binary. The flags local, web-search, stt, and config-ui are on by default. A headless build without local refuses any configuration that declares local models; the refusal happens at startup.
Run it as a service on Linux
On Linux the release archive contains a sample systemd unit. The unit runs the gateway as a service with a fixed config path and profile, and restarts it automatically on failure:
ExecStart=/usr/local/bin/promptforge-gateway --config /etc/promptforge/gateway.toml --profile main
Restart=on-failure
RestartSec=5
The gateway holds vendor credentials, so run it as a dedicated unprivileged user. The sample unit does this with DynamicUser=yes and keeps state in a systemd-managed state directory (StateDirectory=promptforge).
Watch the logs
A serving gateway logs to gateway.log in the logs directory under the state directory (~/.promptforge/logs on a default install) and mirrors the same stream to stdout. Startup rotates the previous run's log aside - gateway.log becomes gateway.log.1 - and keeps five previous runs, deleting the oldest. Every record crosses a redaction pass before it reaches disk: bearer tokens, authorization and cookie header values, and api_key assignments are masked. The log location is never configurable, so a config failure still has somewhere to report itself.
Control log verbosity through the standard RUST_LOG environment filter. The speech library logs at warn level by default, so it stays quiet unless you ask for more.
Startup failures appear on stderr with the full cause chain: one error: line followed by one caused by: line per cause, and the same chain lands in the log file. Once the gateway is serving, the log shows the bound address. If you configured port 0, the log reports the real bound port.
Inspect a failed run
When a gateway run fails before it can serve, promptforge-gateway diagnostics finds the evidence without any config knowledge. It prints a read-only JSON report: the state directory, the resolved config path and whether it exists, the current and retained log paths and which exist, the gateway discovery file, whether a gateway is running, and the version. It never serves, rotates a log, parses a config, or mutates the state directory, and it never prints secrets - no bearer key, environment value, config content, or log content. The generated config points at it in a comment.
Stop the gateway
From the tray, choose Quit. From a script or another PromptForge component, send an authenticated POST to the /shutdown route with the bearer key; it answers 202 and then the server goes down. Under --no-tray, Ctrl-C stops the gateway cleanly. Every path drains in-flight requests before the exit.
The Configuration File
This chapter teaches you the shape of the one file that configures the whole gateway. You will learn the version key, the server section, how to keep secrets out of the file, when a same-machine caller needs no key, and the loopback wall that guards the admin surface. Every other chapter adds sections to this file, so a solid mental model here pays off everywhere.
One file, one version
You configure the gateway in a single version-2 gateway.toml file. The file owns the global settings, the complete model catalog, and the profiles. The file must declare its version on the first line:
config-version = 0
Any other version fails to load. There is no silent upgrade path.
A minimal configuration
A minimal configuration has one [server] section, one or more [[endpoint]] backends, and one or more [[model]] entries that map public names to upstream aliases:
config-version = 0
[server]
bind = "127.0.0.1:8081"
api_key = "${GATEWAY_KEY}"
[[endpoint]]
id = "openai"
protocol = "openai"
base_url = "https://api.openai.com/v1"
api_key = "${OPENAI_API_KEY}"
[[model]]
name = "gpt-5"
kind = "chat"
description = "GPT-5 via OpenAI"
context = 272000
thinking = "switchable"
upstream = "gpt-5"
endpoints = ["openai"]
The [server] section sets the socket address and the shared bearer key. Every request from another machine must present the key, and a key that is presented is always checked. The key must not be empty. A third field, trust_loopback, controls whether callers on the gateway's own machine may skip the key. It defaults to true, which on a shared machine also admits every other OS account there; set trust_loopback = false to require the key from everyone. The rule is covered in full below.
The model catalog lives in the same file. Remote models are [[model]] entries. Local models are [[local_model]] entries. Speech models are [[stt_model]] entries. Later chapters cover each kind.
Keep the sections in the canonical order: config-version, [server], [workshop], [local], [tools], [[dominion]], [[endpoint]], [[model]], [[local_model]], [[stt_model]], [[profile]]. The order minimizes merge noise when two people edit the file.
Keep secrets out of the file
Reference environment variables in string values with ${VAR} syntax:
api_key = "${OPENAI_API_KEY}"
A literal dollar sign is written $$. Interpolation runs only on string values, after the TOML is parsed, so a variable reference inside a comment or a key is never expanded. An unclosed ${...} fails the load. A reference to an unset variable fails the load with a distinct error that names the variable.
At startup the gateway loads the config-sibling .env file into the process environment before it reads the config. Variables already set in the environment win. A missing variable surfaces later as the unresolved ${VAR} error.
Secrets never serialize. When the gateway renders the configuration, every secret field shows *** instead of credential material. You can view the running configuration rendered as JSON in TOML shape, and you can list which config fields reference each ${VAR} variable; the values are never exposed.
Validation never lets a bad file load
A configuration never loads without passing validation. Unknown keys in any section are rejected, never ignored. Removed layout features, such as include chains or a sibling profiles directory, fail with hard-break diagnostics that name the file, the removed key, the source line, and the replacement layout. Removed legacy keys such as [queue] or an endpoint's concurrency fail at parse time. An old config cannot silently load.
You can classify a load failure into stable kinds: unreadable file, invalid TOML, malformed interpolation, unset environment variable, failed semantic check, removed layout feature, or shadow write failure.
Two field rules are worth memorizing early. A sha256 pin must be exactly 64 hexadecimal characters; uppercase and surrounding whitespace are accepted and normalized to lowercase. And a [[model]] entry without a description or a context is rejected at load.
Loopback trust
By default a caller on the gateway's own machine needs no key. With trust_loopback = true (the default, and what the first-run config writes), a request from a loopback peer that presents no credential at all is admitted on every route, the admin surface included. That is what lets curl http://127.0.0.1:8081/v1/models, the SDK with only PROMPTFORGE_GATEWAY_URL set, and the config UI on its own origin work without a key.
The trust is narrow on purpose. It applies only when the request carries no Authorization header: a presented-but-wrong bearer is still rejected with 401, even from loopback, so a stale key is always detected. And it applies only when the request's fetch metadata allows ambient access: no Sec-Fetch-Site header (curl, the SDK, any non-browser client) or a value of same-origin or none (the config UI, a typed URL). A page on another origin sends cross-site, and browsers never let a page strip that header, so a web page cannot ride your loopback peer into the admin surface. A request with no peer address fails closed and needs the key.
The cost is the shared-machine case. On a machine with more than one OS account, any other account can use your gateway, including reading upstream API keys from the admin config surface. If that describes your machine, set trust_loopback = false to require the bearer key from every caller, or bind the gateway off loopback:
[server]
bind = "127.0.0.1:8081"
api_key = "${GATEWAY_KEY}"
trust_loopback = false
[server] is process-owned, so a change to trust_loopback takes effect on the next restart.
The loopback wall
The admin config endpoints sit behind a loopback wall in every build. A non-loopback peer gets 403 before bearer auth even runs. The wall covers config read and write, the env file, pending state, apply and revert, orphans, system metrics, model info, chat templates, the Hugging Face proxy, profile create and delete, and reveal. The wall fails closed: a request with no peer address is refused. Loopback trust adds a rule to authentication; it removes no wall.
Derived addresses
An unspecified bind IP such as 0.0.0.0 or :: becomes the matching loopback address in derived client URLs. Same-host consumers, including a hosted workshop, always get a dialable URL.
Remote Models and Endpoints
This chapter teaches you to declare remote backends and the models they serve. You will learn endpoint entries, model entries, and the catalog your callers see. Remote models are the simplest way to get the gateway serving, so they come first.
Declare an endpoint
A remote backend is a [[endpoint]] entry. Start with one:
[[endpoint]]
id = "openai"
protocol = "openai"
base_url = "https://api.openai.com/v1"
api_key = "${OPENAI_API_KEY}"
Each entry has an id, a protocol of openai, a base_url, an api_key, and an optional dominion binding. A trailing slash on the base URL is trimmed. Endpoint ids must be non-empty and unique. Each base_url must be an absolute http or https URL with a host; values like not-a-url or ftp://example.com fail validation.
Declare a model
A remote model is a [[model]] entry that maps a public name to the alias the backend knows:
[[model]]
name = "gpt-5"
kind = "chat"
description = "GPT-5 via OpenAI"
context = 272000
thinking = "switchable"
upstream = "gpt-5"
endpoints = ["openai"]
Each entry has a name, a kind, a description, a context size, a thinking mode, an upstream alias, a list of endpoints, an optional default_max_tokens, and an optional tool_dialect. The upstream alias is the string the backend knows the model by.
Every remote model must list at least one endpoint, and every endpoint it names must be defined. Model names must be unique across remote and local models, so one name always refers to one model.
Kinds and thinking modes
Every model carries a kind: chat, embedding, classifier, or speech. The kind scopes which fields are meaningful. Chat-only fields such as thinking and default_max_tokens are rejected for non-chat kinds at load time.
Record each chat model's thinking behavior as never, always, or switchable. Switchable means the client may toggle thinking per request.
Tool dialects
The default openai tool dialect forwards tool definitions to the backend verbatim. For a backend without a native tool array, set the emulating dialect on a chat model:
tool_dialect = "gemma3_tool_code"
With this dialect the gateway injects a tool guide into the system prompt, strips the tool fields from the outgoing request, and parses tool fences from the reply.
Advertise capabilities
You can advertise per-model capabilities that surface verbatim on GET /v1/models, so clients can shape requests before sending them:
[model.capabilities]
max_output = 16384
default_temperature = 1.0
images = true
parallel_tool_calls = true
effort_levels = ["low", "medium", "high"]
default_effort = "medium"
adaptive_thinking = true
The capability fields are max_output, default_temperature, images, parallel_tool_calls, effort_levels, default_effort, and adaptive_thinking. They obey cross-field rules at load time. A default_effort without effort_levels fails. A default_effort not listed in effort_levels fails. Effort fields fail when thinking is never. A max_output larger than context fails; an exact fit passes.
Enumerated fields accept a fixed spelling vocabulary. Use the spellings verbatim: protocol openai; thinking never, always, or switchable; tool_dialect openai or gemma3_tool_code; model kind chat, embedding, classifier, or speech.
What the caller sees
Callers observe the catalog at GET /v1/models:
curl -H "Authorization: Bearer $GATEWAY_KEY" http://127.0.0.1:8081/v1/models
Each configured model carries its caller-facing id, its workload kind, its description, its context window size, its thinking mode, and its capability metadata.
When a caller sends a chat, embedding, or rerank request, the gateway forwards it to the backend paths chat/completions, embeddings, or rerank relative to the configured base URL. The public model name is rewritten to the upstream alias. The caller's bearer token is never sent upstream.
Local Models
This chapter teaches you to run models on your own machine through the gateway. You will learn to declare a local model, how the gateway provisions and verifies it, and how the managed child processes behave. Local models share the gateway's OpenAI routing with remote models, so everything you learned about the catalog still applies.
Declare a local model
A gateway-hosted model is a [[local_model]] entry. Start with the smallest useful declaration:
[[local_model]]
name = "qwen3-local"
kind = "chat"
description = "Qwen 3 8B, local"
source = "https://huggingface.co/qwen/qwen3-8b/resolve/main/model.gguf"
sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
vram_gb = 6.0
context = 8192
thinking = "switchable"
Each entry has a name, a kind, a description, a source, and sizing and serving knobs. The source is an https URL or a local GGUF path. The knobs and their defaults are: parallel default 1, vram_gb, context, thinking, gpu_layers default 99, flash_attention default true, cache_type_k default q8_0, cache_type_v default q4_0, and n_predict default 8192. A local model may also bind to a local dominion with the optional dominion key, and every model bound to the same dominion shares that dominion's concurrency limit. The gateway renders the child's launch flags directly from these knobs: the context size, the generation ceiling, the parallelism, the KV cache types, the GPU layers, the flash attention, and the chat template file.
A model downloaded from an https URL must be pinned by a sha256 digest. A plaintext http source is rejected, even with a valid pin. A local filesystem path may be unpinned, and the path may use ~ expansion. The pin is verified after download and on every cache hit.
The cache directory
Set [local].cache_dir for GGUF files and the pinned llama-server install:
[local]
cache_dir = "~/.promptforge"
The default is ~/.promptforge, or %USERPROFILE%\.promptforge on Windows, where the location inherits the per-user ACL. Models land in <cache_dir>/models, keyed by a hash of the full source URL, so two distinct URLs that share a filename never collide. The llama.cpp runtime installs in <cache_dir>/llama.cpp.
On Windows x86-64 you can pick the llama-server build with [local].llama_backend: auto, cuda-blackwell, cuda, or vulkan. The auto setting picks from the host's GPUs. You can also force an explicit llama-server executable with [local].llama_server_path; it wins over the PROMPTFORGE_LLAMA_SERVER environment variable and the managed download.
What runs underneath
Local inference runs on a pinned llama-server build, b10082. The gateway prefers GPU-enabled archives per platform: Vulkan on Windows and Linux, Metal on macOS. The gateway never compiles native dependencies at runtime; it downloads, verifies, stages, and launches pinned archives. A completed runtime install records its archive pins and a tree digest in a marker file, and a valid install skips re-extraction on later starts.
The gateway runs one managed llama-server child per [[local_model]] in the boot profile's checklist. The set of children is fixed for the process lifetime: it is decided by the profile selected at boot, and changing it means selecting a profile or editing the local catalog and restarting. Children get supervised respawn and deterministic teardown at shutdown. Staged CUDA bundle directories are prepended to the child process's PATH only; the gateway's own environment is never mutated. Local models appear to clients as ordinary routed models under their configured names.
A local model's kind selects the child's serving mode: embedding models serve embeddings, and classifier models serve reranking. A speech kind has no local serving mode and is refused at launch: local speech models are not yet supported. The parallel key sets both the child's concurrency and its admission limit. The thinking setting changes the child's sampling preset: thinking models sample at temperature 1.0 and top-p 0.95, while non-thinking models run with reasoning switched off and sample at 0.7 and 0.8.
Chat templates
A local chat model needs a chat template. The gateway resolves one through a fixed precedence:
- An explicit
chat_template_filepath. - A
chat_template_file = "builtin:<family>"setting. - A known-override match.
- The GGUF embedded template.
A model with no usable template refuses to launch, and the error names the model and the fix. The bundled catalog has twelve template families: ChatML, Llama 3, Llama 3.1, Qwen 2.5, Qwen 3, Gemma 3, Gemma 4, Mistral, Phi 3, Phi 4, GPT OSS, and Zephyr. Family names accept documented aliases, and case and surrounding whitespace are ignored. The gateway also recognizes 181 revision-pinned Hugging Face repository IDs and maps each to its family automatically. Models with a known-broken embedded template are silently repaired with a bundled corrected template. The configuration UI can show the effective template source and a plain-language reason before the model is downloaded.
Reading the GGUF header
The gateway reads the architecture, the layer count, the parameter count, and the embedded chat template straight from each GGUF header, without loading tensor data. A malformed or hostile GGUF is rejected with a typed error instead of a crash or an unbounded read.
Companion artifacts
Attach a speculative-decoding drafter to a chat model with a [local_model.speculative] sub-table:
[local_model.speculative]
type = "draft-mtp"
source = "https://huggingface.co/qwen/qwen3-8b/resolve/main/drafter.gguf"
sha256 = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"
draft_max = 4
The only type is draft-mtp, and draft_max is bounded to 1 through 16. Attach a multimodal projector with a [local_model.multimodal_projector] sub-table carrying a source and a sha256 pin; a model with a projector accepts image inputs.
Companion artifacts follow the main-model source rule: an https URL must be pinned, a local path may be unpinned, and plaintext http and empty sources are rejected. Companions on a non-chat model kind fail validation. Companions are provisioned and pin-verified before the child launches, and any failure aborts the launch.
Downloads and verification
Artifact downloads are bounded. The connect timeout is 30 seconds, the whole-request ceiling is 2 hours, and a single artifact is capped at 256 GiB. Cache lookups refuse path traversal and absolute paths before any file is read, so a crafted model path cannot escape the cache root. An interrupted download resumes from the partial file's offset when the source URL still matches. A partial download from a different source restarts from zero. A pin mismatch on a cached blob is repaired by re-downloading. Once a blob passes its pin check, later runs skip re-hashing. When a runtime download fails and an older verified install exists, the gateway uses the cached install with a warning. Bundled runtime assets, including the chat templates, are written into the cache only after a SHA-256 verification pass, and a cached copy whose bytes have drifted is repaired from the bundled copy.
Authenticate gated Hugging Face downloads with the HF_TOKEN or HUGGING_FACE_HUB_TOKEN environment variable. The token is attached only to HTTPS requests to huggingface.co and its subdomains.
Models downloaded from Hugging Face get a metadata sidecar file beside the cached GGUF. The sidecar records the source URL, the fetch time, the chat template, and an optional model card excerpt.
Startup and supervision
Startup reports a structured progress tree under the boot load's stages: loading-profile, downloading-models, starting-models, and loading-speech. One subtree covers the llama-server runtime, and each local model gets download, verify, and ready stages. Progress renders as tracing log lines on every stream and on the live progress stream.
Startup is best-effort. Every model that launched keeps serving, and each model that failed is reported by name with its error. One bad model never blocks the rest. Startup failures are classified as plausibly transient or permanent, and the classification annotates the respawn diagnostics you see in the logs.
Each child server listens only on loopback, and each launch uses a fresh random alias and bearer key, so other processes on the machine cannot ride the local endpoint. Responses still carry your configured model name. Startup waits up to 180 seconds for a child to become ready, and a port collision retries on a fresh port up to four times.
A child that dies is transparently respawned on the same port, alias, and key, with a 3 second cooldown between attempts so a crash loop cannot storm. Only transport-level deaths trigger a respawn, and an explicitly shut-down child is never respawned. Shutdown cancels and terminates even an in-flight respawn. Teardown is bounded to 5 seconds, so shutdown never hangs.
Child stdout and stderr are captured into bounded tails with the credential redacted. You can pull the tails per model as diagnostics; they include the CUDA device report and per-model GPU offload lines. At startup the gateway also probes each local chat model to detect native tool-call support and picks the correct tool-calling dialect from the evidence.
On Windows the child runs at below-normal priority with no console window, so weight loading and inference yield to interactive desktop use.
What callers can do
Local chat completions accept deterministic sampling parameters such as temperature, seed, presence_penalty, and max_tokens. They also accept tool definitions and, with a projector, image inputs. Chat completions on a speculative-drafted model expose decoding statistics in the response's timings extension: draft_n and draft_n_accepted.
Speech-to-Text
This chapter teaches you the gateway's transcription surface: how to declare speech models, how the interim and final roles work together, and how to use batch and Realtime transcription. Speech builds on local models, because speech models are provisioned and cached the same way.
Declare speech models
A speech-to-text model is a [[stt_model]] entry:
[[stt_model]]
name = "whisper-base-en"
role = "interim"
source = "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin"
sha256 = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
vram_gb = 1.0
Each entry has a name, a role of interim or final, a source, an optional sha256 pin, a vram_gb estimate, and an optional dominion binding. The interim role transcribes while a take is still recording. The final role crystallizes completed audio.
A profile may select at most one interim and one final STT model. A final model requires an interim partner. Interim-only is a supported degraded mode. You can restore a built-in recommended pair at any time: whisper-base-en for interim and whisper-small-en for final, both carrying canonical whisper.cpp URLs and SHA-256 pins.
Tune push-to-talk capture
Tune the pipeline in the optional [stt] section:
[stt]
window_seconds = 15
interval_ms = 500
vocabulary = ["MCP", "GGUF", "Lua"]
The window_seconds key sets the seconds of trailing audio transcribed per pass (default 15), and interval_ms sets the milliseconds between passes (default 500). Each must be at least 1; a zero value fails startup. The vocabulary lists domain terms that bias both transcription workers toward those terms. An empty list disables biasing. A vocabulary that exceeds the model's prompt budget is truncated, and a warning is logged.
Version 2 accepts only the canonical [stt] section. Legacy [workshop.stt] input is rejected as an unknown workshop field whether it appears alone or beside [stt], and saved configuration uses only [stt].
Batch transcription
With the default-on stt feature the gateway serves OpenAI-compatible audio transcription at POST /v1/audio/transcriptions. The multipart form accepts file, model, language, prompt, temperature, response_format, and the repeated field timestamp_granularities[].
Uploads are capped at 25 MiB; an over-limit upload is answered with "audio file exceeds the 25 MiB limit". Only 16 kHz mono WAV audio is accepted. Other sample rates or channel counts are rejected with a message naming what was received.
Two response shapes are offered. The json shape returns text only. The verbose_json shape returns task, language, duration, text, segments, and words. Segment timestamps are on by default, and word timestamps are always empty. A transcription request for a model not loaded in the active profile is rejected as an unknown model. A caller-supplied temperature must be a finite non-negative number. The prompt hint is accepted but ignored by the current English whisper workers.
The runtime
Speech-to-text runs on a separately pinned whisper.cpp library bundle, b4938. A library that does not match the pinned layout fails to load, and only 64-bit targets are supported. The gateway serves first and loads speech second: after the listener is bound, the queued boot command downloads and verifies the model artifacts and the runtime into the configured cache directory, with progress on the status and progress endpoints. Each model file is prewarmed and then loaded, with progress per model. Speech routes answer as unavailable until the load completes, and the model catalog advertises speech models only once the engine is ready.
STT startup failures are named by stage: opening the artifact store, provisioning the whisper library, provisioning a named model, a missing interim partner, an unsupported role, or engine load. Library load failures name the failing path or symbol in the logs. A failed boot load never stops the gateway and is never retried in-process: speech stays unavailable, the failed boot command shows on the queue and progress surfaces, and a restart is the recovery.
How a take is transcribed
A recorded take is split into speech segments at silence boundaries. A segment closes only after 2 seconds of trailing silence, so sentence-internal pauses survive. Speech bursts shorter than 250 ms are discarded as clicks. Audio quieter than -60 dBFS is treated as silence and never sent to the model, and fragments shorter than half a second are gated out.
With a final model configured, completed speech segments are re-transcribed in the background while the take still records, and each segment's text is reported as it finishes. Without a final model, the stop falls back to the interim model. Silent or very short fragments are skipped so the model does not invent text for them. Transcription is pinned to English, and translation is disabled.
Realtime transcription
The gateway serves authenticated Realtime transcription at WS /v1/realtime?intent=transcription. The query is exact: missing, duplicate, malformed, unsupported, or additional parameters are rejected before upgrade. Native clients may omit Origin; browser clients must send an HTTP loopback Origin.
The server creates a transcription session for the logical model realtime-transcribe. Clients may send session.update, input_audio_buffer.append, input_audio_buffer.clear, and input_audio_buffer.commit. Audio appends are canonical Base64 containing signed little-endian mono PCM16 at 24 kHz. The gateway preserves an odd trailing byte across appends, continuously resamples to 16 kHz, flushes the resampler on commit, and resets the whole input on clear.
Only null noise reduction and turn detection are accepted. Session updates may change the transcription prompt and negotiate the PromptForge extension item.input_audio_transcription.hypothesis. Standard clients receive OpenAI-shaped session, item, transcription delta, completed, failed, and error events. Extension clients also receive revisioned replacement snapshots with the complete transcript and its finalized, agreed, and tentative regions; completion remains authoritative.
A continuous Realtime recording remains one provisional item and one logical take for arbitrary duration. Continuous speech forces an accurate boundary every 10 seconds. Each successor carries the preceding 8 seconds for text reconciliation, so every accurate decode contains at most 18 seconds. Finalized text and exact lifetime duration survive source-buffer compaction, and every hypothesis remains a complete replacement snapshot for that same item.
The 30-second limit is retained ownership, not recording duration. It includes resident, queued, and actively decoding 16 kHz PCM. Arbitrary-duration capture therefore requires steady-state final throughput at least equal to capture. If decoding falls behind until that retained budget is exhausted, the append receives too_much_unfinalized_audio; previously accepted audio and text remain valid and may still be committed. One append decodes to at most 15 MiB, committed audio must be at least 100 ms, one connection may have four committed items finalizing concurrently, and the service admits at most eight Realtime sessions. Queue and capacity overloads return explicit errors instead of waiting without limit.
The desktop Workshop exposes the same /v1/realtime path on its own origin. Its server authenticates the fixed upstream target and relays payloads without parsing them, so the webview never receives the gateway credential.
Speech loads exactly once per process, from the profile active at boot. Switching the active profile or applying a new configuration persists a changed speech selection but never loads, reloads, or unloads the running engine; the new selection takes effect on the next start. The configuration UI raises a restart toast when an apply changes the speech tuning, the speech model catalog, or the active profile's speech membership.
Speech Synthesis
This chapter teaches you the gateway's speech synthesis surface: how to declare a speech model, how to call the synthesis route, and how to enumerate voices. Synthesis builds on remote models, because the gateway routes speech to remote providers only; a [[local_model]] with kind = "speech" is refused at launch.
Declare a speech model
A speech synthesis model is an ordinary [[model]] entry with kind = "speech", backed by an ordinary [[endpoint]]:
[[endpoint]]
id = "together"
protocol = "openai"
base_url = "https://api.together.xyz/v1"
api_key = "${TOGETHER_API_KEY}"
[[model]]
name = "orpheus"
kind = "speech"
description = "Orpheus 3B conversational speech synthesis"
upstream = "canopylabs/orpheus-3b-0.1-ft"
endpoints = ["together"]
context = 8192
voices = ["tara", "leah", "jess", "leo", "dan", "mia", "zac", "zoe"]
The entry carries the usual remote-model fields, and the kind scopes which of them are meaningful. Chat-only fields such as thinking, the effort knobs, default_max_tokens, and tool_dialect are rejected on a speech model at load time. The speech-only voices list declares the voices the model offers: setting it on any other kind fails at load, entries must be non-empty and unique, and an empty or omitted list means the model exposes no fixed voice list, so the route accepts any voice name. The catalog advertises the kind and the voice list verbatim on GET /v1/models, so clients can shape requests before sending them.
Synthesize speech
The gateway serves OpenAI-shaped speech synthesis at POST /v1/audio/speech:
curl -H "Authorization: Bearer $GATEWAY_KEY" http://127.0.0.1:8081/v1/audio/speech \
-H "Content-Type: application/json" \
-d '{"model": "orpheus", "input": "The quick brown fox.", "voice": "tara"}' \
-o speech.mp3
The request carries model and input (both required; the input is non-empty and capped at 4096 characters), voice (required; a plain name or the OpenAI object form {"id": "tara"}), and four optional fields: response_format from the closed set mp3, opus, aac, flac, wav, pcm; speed between 0.25 and 4.0; instructions, the gpt-4o-mini-tts dialect's style-control string; and stream_format, sse or audio. An omitted response_format resolves to mp3 before the request leaves the gateway: OpenAI defaults to mp3 while Together defaults to wav, so the pin lives in the wire type and every forwarded body carries it. Fields the gateway does not name pass through to the provider verbatim, so provider extras such as Together's sample_rate ride the same request, and angle-bracket emotion tags such as <laugh> in the input reach the provider untouched.
Authentication runs before the body is parsed, so a bad key earns 401 even for a malformed body. Shape failures earn 400: an empty or over-cap input or an out-of-range speed as malformed_request, a non-speech model as kind_mismatch, and a voice outside the model's declared list as invalid_voice naming the valid voices, judged before queue admission so the rejection never burns a queue slot. A full queue earns 503 with code queue_full. An upstream 429 comes back as 429 with code upstream_rate_limited and an upstream 503 as 503 with code upstream_unavailable, so an OpenAI client sees a retryable rate-limit or server error rather than a generic failure.
The response is the provider's audio bytes streamed through unread: the gateway forwards the upstream Content-Type (when missing or invalid, the framing selector first, so an SSE stream is labeled text/event-stream and never an audio type, then the requested format's MIME type), never sets Content-Length, and emits Transfer-Encoding: chunked. No JSON error can follow 200 plus audio bytes, so a mid-stream upstream failure surfaces as a truncated body and the client's read fails.
List voices
GET /v1/audio/voices answers the union of the active profile's speech voices, deduplicated and sorted:
{"voices": [{"id": "dan", "name": "dan"}, {"id": "jess", "name": "jess"}, {"id": "leah", "name": "leah"}]}
Each entry is an object with id first and name mirroring it, because the catalog configures voices as bare strings with no separate display name. OpenAI has no voice-list route; the OpenAI-compatible ecosystem converged on this one, and clients such as Open WebUI read the id key, so the entry shape is a compatibility surface. Tolerant clients also accept the plain-string form some servers answer with. A profile with no speech models returns an empty list, and non-speech models contribute nothing.
The stream_format caveat
stream_format = "sse" is OpenAI's selector for event-stream framing, and the gateway forwards it verbatim like any other field. Provider dialects differ: Together spells its streaming mode stream=true, and only with response_format = "raw", which the wire enum rejects, so Together SSE cannot be requested in phase 1 and Together speech is non-streaming. The forwarding is forward-looking, aimed at SSE-capable OpenAI-compatible providers, and the gateway does no reframing or decoding: whatever framing the provider answers with passes through untouched, so a client that receives an event stream owns decoding it itself.
Profiles and Selection
This chapter teaches you profiles: named checklists that decide which local models the gateway loads, how the selection is stored, and how you change it. Profiles are how one config file serves a work machine, a travel laptop, and a demo box without editing a single model entry.
Define a profile
A profile is a [[profile]] entry that owns only a name and a models list:
[[profile]]
name = "work"
models = ["qwen3-local", "whisper-base-en", "whisper-small-en"]
[[profile]]
name = "travel"
models = ["qwen3-local"]
A profile is a checklist of local and speech-to-text models. Membership alone decides which local models spawn and which speech models load; profiles carry no per-field overrides. Every name a profile lists must be a [[local_model]] or [[stt_model]] entry, and each must exist exactly once. Naming a remote [[model]] in a profile fails validation with an error saying the model is remote: remote models are never gated by a profile, because every [[model]] in the catalog routes all the time. Duplicate profile names and duplicate members also fail validation.
Profile names must be a single safe path component: no surrounding whitespace, not empty, not . or .., and no path separators. One spelling works in URLs, state files, and labels.
Every profile is validated at load. Names are unique and legal, every listed model exists, each profile selects at most one interim and one final speech model, and the local and speech subsets are checked against dominion VRAM budgets. The gateway never boots into an invalid profile.
Where the selection lives
The selected profile lives in a sibling state file, not in the config. A gateway.toml maps to a gateway.state.toml holding one canonical key:
active_profile = "work"
The selection survives restarts. An absent state file is the persisted form of "no profile": the gateway boots, serves every remote model, and loads no local or speech models. "No profile" is a selectable state, not an error.
At startup the profile is chosen by precedence: the --profile command-line flag, then the PROMPTFORGE_PROFILE environment variable, then the sibling state file. The flag and the variable are ephemeral; they never write the state file. With none set, the gateway boots with no profile.
A state file naming a profile the config no longer defines does not stop the boot. The gateway logs a warning naming the stale value and the defined profiles, then boots with no profile. The stale name stays in the state file until you select something else, and the configuration UI shows it as a stale selection. A --profile flag or PROMPTFORGE_PROFILE value naming an undefined profile is still a startup error, because an operator typed it for this run.
The local model set is fixed at boot
The gateway loads its local models once, at boot, from the profile it started with. After the listener is bound, one boot command downloads the profile's local model artifacts, spawns the llama-server children, publishes each into the routing table as it becomes ready, and performs the process's one speech engine load. While a local model is still downloading or spawning, a request for it gets 503 with code model_loading and Retry-After: 5, GET /admin/status lists it under loading_models, and GET /v1/models lists only routable models. When only some local models start, the boot reports which loaded and which failed, and the ones that loaded keep serving.
Nothing after boot changes the set of local models. There is no live switch, no drain of in-flight requests, and no stop-and-spawn of children while the gateway serves. Remote models are the exception: every [[model]] routes from boot, and an applied edit to the remote catalog reloads routing live, as the next chapter explains.
Select a profile
Select a profile over HTTP:
curl -X POST -H "Authorization: Bearer $GATEWAY_KEY" \
-H "Content-Type: application/json" \
-d '{"name": "travel"}' \
http://127.0.0.1:8081/admin/switch-profile
The request body carries name: a profile name, or null to select no profile. The gateway checks a named profile against the loaded catalog and refuses an undefined name with the list of defined profiles. It then writes the state file (or deletes it for null) and answers plain JSON:
{"profile": "travel", "restart_required": true}
restart_required is true when the selection differs from the profile the process is running. The selection persists at once; the running gateway keeps serving its boot profile until it restarts. Selecting the profile that is already running answers restart_required: false and changes nothing. Selection uses the in-memory catalog; the config file is never re-read from disk.
Restart the gateway to load the selection. A gateway the Workshop supervises is restarted by the Workshop when you pick a profile from its Model menu; a gateway you run yourself restarts by hand, and the configuration UI shows a banner reading "Restart the gateway to apply these changes." until the new process comes up.
The selection is not part of the config edit surface. PUT /admin/config refuses a document carrying active_profile, and GET /admin/config-dirty never reports it. GET /admin/config-pending reports the persisted selection under profile.active_profile, read from the real state file, so a client can show a selection that differs from the running profile or names a profile the config no longer defines.
Dominions and Queues
This chapter teaches you dominions: named compute pools that cap concurrency, park or reject excess callers, and schedule waiting clients fairly. Dominions are how you keep one busy model from starving the rest.
Declare a dominion
A dominion is a [[dominion]] entry:
[[dominion]]
id = "pool-r"
kind = "remote"
max_concurrency = 4
max_queue = 100
policy = "queue"
fair_scheduling = true
Each entry has an id, a kind of remote or local, a max_concurrency, a max_queue defaulting to 100, a policy of queue or reject defaulting to queue, a fair_scheduling flag defaulting to true, and a vram_gb budget for local pools.
Bind an endpoint or a local model to a dominion by name:
[[endpoint]]
id = "openai"
protocol = "openai"
base_url = "https://api.openai.com/v1"
api_key = "${OPENAI_API_KEY}"
dominion = "pool-r"
Endpoints bind to remote dominions, and local models bind to local dominions. A wrong-kind or undefined binding is rejected. An endpoint or local model without a dominion binding is unlimited; it behaves as when no cap is set at all.
Budget VRAM
A local dominion can carry a vram_gb budget, and each profile's selected models must fit within it:
[[dominion]]
id = "gpu0"
kind = "local"
max_concurrency = 2
vram_gb = 24
An overflow fails validation with an error naming the dominion and the excess. Fractional estimates such as 1.22 are accepted. Zero, negative, NaN, and infinite estimates fail.
Choose a full-capacity policy
The default queue policy parks callers up to the depth limit. The reject policy turns the caller away immediately, and the gateway answers 429:
policy = "reject"
You can distinguish admission failures by status code. A full waiting queue answers 503 with code queue_full. A fail-fast rejection answers 429 with code queue_rejected. A queue torn down while the caller waited reports the queue as unavailable.
Schedule fairly
Turn on fair scheduling so waiting callers are served in per-client round-robin order, keyed by the X-PromptForge-Client request header:
fair_scheduling = true
The header is a self-asserted hint. Values over 64 bytes or outside the alphanumeric, dash, underscore, dot, and colon charset fold into the shared default bucket. At most 32 distinct client labels are tracked.
How slots behave
A streaming request holds its dominion concurrency slot for the stream's whole lifetime, so a second request waits until the first stream ends. A cancelled queued request frees its waiting slot, and capacity recovers without a restart.
Editing Configuration Safely
This chapter teaches you the safe-edit surface: how the gateway stages edits in shadow files, how you preview and apply them, and how you recover when an edit is wrong. Editing through this surface means a bad config can never take down a running gateway.
Shadow files
Pending admin edits are staged in one shadow file, gateway.toml.next, beside the real config. No save touches a real file until promotion.
Stage a full config edit with PUT /admin/config. The request takes the same JSON shape that GET /admin/config returns. Secrets left as the redacted marker *** are restored from the current values, and a marker with no existing value fails validation. The merged result is validated like a real load before any shadow is written. The reply names the shadow file that was written.
The profile selection is not a config key. A document carrying active_profile is refused with a validation error pointing you at POST /admin/switch-profile, which the previous chapter covers.
Preview before you apply
Preview the merged pending configuration with secrets still redacted:
curl -H "Authorization: Bearer $GATEWAY_KEY" http://127.0.0.1:8081/admin/config-pending
The pending envelope also reports the persisted profile selection under profile.active_profile, read from the real gateway.state.toml: null when no profile is selected, otherwise the stored name even when the running profile differs or the config no longer defines it.
Poll a cheap dirty report of pending shadow files and changed sections:
curl -H "Authorization: Bearer $GATEWAY_KEY" http://127.0.0.1:8081/admin/config-dirty
Apply
Applying a pending edit is an explicit promote step:
curl -X POST -H "Authorization: Bearer $GATEWAY_KEY" http://127.0.0.1:8081/admin/config-apply
The real file is replaced atomically. On platforms where rename cannot overwrite, a backup-and-restore fallback preserves the old file. The reply carries applied, reloaded, and restart_required.
What an apply does depends on which sections changed. The remote-facing sections - [[model]], [[endpoint]], [[dominion]], and [tools] - reload live: the gateway rebuilds the remote routing table from the applied config, keeps the running local models under it, and swaps the routing table in one write. Nothing drains, nothing stops, and no child process starts. The boot-owned sections - [server], [workshop], [[profile]], [[local_model]], [[stt_model]], and [stt] - promote to disk but take effect at the next start, so the reply carries restart_required: true; an env shadow does the same. The gateway's local model set is fixed for the process lifetime, so an edit that adds, removes, or changes a local or speech model, or changes a profile's checklist, always needs a restart. One apply can do both: reload the remote catalog now and report a restart for the rest.
An apply that changes the config runs as a command on the gateway's command queue, the same queue that runs the boot load. The queue is one serialized pending deque with no fixed capacity: debounce decides what stays pending, and a single worker runs the surviving commands in order. The request waits for the command's outcome, so the call above still returns when the apply is done. While the command runs, GET /admin/status reports it as the active command named apply-config, and its one applying-config stage streams on the live progress stream; the config UI's Apply overlay follows it and carries a Cancel button. POST /admin/queue/cancel stops it; the request then answers 503 with error code apply_cancelled. An apply requested while the boot load is still running waits behind it, so a reload never races the boot's publication of local models. An apply that touches only the env file, or only boot-owned sections, needs no reload and runs inline without a command.
Promotion happens at the end. The shadow is read into memory when the apply is requested, the new routing table is built, and only then are the captured bytes written to the real file and the shadow removed. A cancelled or failed apply therefore promotes nothing: the shadow stays on disk, the pending count stays where it was, and the next Apply runs the whole thing again. A save that lands while an apply is in flight is kept as the next pending change, never silently lost and never half-applied.
Revert
Discard every staged edit without touching the real files:
curl -X POST -H "Authorization: Bearer $GATEWAY_KEY" http://127.0.0.1:8081/admin/config-revert
The reply names the deleted shadow files. Deleting the shadows is the whole revert. A revert never touches the profile selection, because the selection is never staged.
The .env file
Read and stage the gateway's global .env file over the same surface. GET /admin/env returns the file with plaintext values and shows which config fields reference each variable. PUT /admin/env stages a .env.next shadow that takes effect after restart. Variable names must use letters, digits, and underscores, and must not start with a digit. Values must round-trip through the dotenv parser.
Failure behavior
You are protected from half-applied state. Saves, revert, profile selection, and the apply's snapshot and commit steps serialize on one lock, and applies serialize with the boot load on the command queue. An invalid pending config is never promoted; the request fails before any command exists. A failed or cancelled apply leaves every shadow on disk for correction, retry, or revert. A revert issued during an apply cancels the apply first, so the apply's commit never writes over files you just reverted.
The Configuration UI
The gateway serves a browser UI for configuration: you reach it over HTTP, sign in with your API key when the gateway asks for one, and edit every part of the configuration through its views. The UI rides the safe-edit surface from the previous chapter, so everything you do there moves through pending shadows and Apply.
Reach the UI
The gateway serves the configuration UI at /config on its own port; there is no second listener. The UI is an optional feature you compile in with config-ui, which is on by default. GET /config redirects permanently to /config/. Five asset endpoints live under /config: the index page at /, the bundled script at /app.js, the stylesheet at /app.css, and the program icon at /icons/promptforge-icon.png with its high-DPI render at /icons/[email protected].
The UI pages need no bearer token, but every asset route answers 403 Forbidden to any peer that is not loopback. The UI is reachable only from the gateway machine itself, and the check fails closed.
Sign in
With the default trust_loopback = true, the UI opens straight into the shell: it runs on the gateway's own machine, and the gateway admits a loopback caller that presents no key. On a shared machine that same trust admits every other OS account, so an operator there sets trust_loopback = false; the UI then asks for the key. On first load without a stored key, you see a "PromptForge Gateway" sign-in card with a labeled API key password field and a Connect button. A wrong key shows "Invalid API key". An unreachable gateway shows "Gateway unreachable". The verified key is stored for the browser session. Any later 401 from the gateway clears the stored key and returns you to the key prompt.
Get oriented
You navigate six top-level views from the tab bar: Settings, Discover, Local, Remote, Profiles, and Secrets. Every view has a bookmarkable hash URL, including a specific model's detail page and a specific Settings section. An unrecognized hash is rewritten to #/local.
A connection dot in the tab bar shows whether the gateway is reachable. The tab bar shows the running UI's version as a muted label, or vdev for a development build. Notifications appear as toasts that dismiss themselves after four seconds. Destructive actions require confirmation in a modal dialog that names the target; focus lands on Cancel as the safe default, and Escape or a backdrop click cancels. Every dropdown works entirely from the keyboard, including typing the first letters of an option to jump to it. The UI is always dark, the reduced-motion system preference disables essentially all animation, and byte sizes appear in human-readable units.
The three states of an edit
Edits move through three states: unsaved edits held in the browser, saved pending shadows on the gateway, and the applied running configuration. When pending changes exist, the tab bar shows an Apply button labeled with the pending file count beside a Revert All button. When a previous session left unapplied changes, a banner offers Review, Apply, and Revert All.
Pressing Apply opens a progress overlay that follows the gateway's live progress stream until the apply finishes or fails; a remote-catalog reload shows as one applying-config stage. The overlay carries a Cancel button; pressing it stops the apply on the gateway, and the overlay reports that the apply was cancelled and your pending changes are still staged. A failed stage holds on the error message for a moment before the overlay closes. When an applied configuration requires a restart - a change to [server], [workshop], [stt], a profile, a local model, or a speech model, or an env edit - a banner reads "Restart the gateway to apply these changes." and clears itself once the gateway comes back on a new config generation. The same banner is raised by a profile selection that differs from the running profile.
Open the Review dialog to list every pending configuration change as a table of path, running value, and pending value. Secret values are never displayed.
Profiles
The tab bar shows the selected profile. Its menu lists "No profile" first and then every defined profile, with the persisted selection checked. Choosing an entry selects it on the gateway at once through POST /admin/switch-profile; the selection is not a pending change and needs no Apply. When the selection differs from the profile the gateway is running, the restart banner appears, because the gateway loads its local models once at boot. A refused selection surfaces an error toast and leaves the current selection unchanged.
In the Profiles view you edit each profile as an ordered subset of the local and speech-to-text catalog through Available and Chosen shuttle listboxes; remote models never appear, because every remote model routes regardless of profile. The listboxes support multi-select, roving focus, typeahead, selection counts, and per-pane search. The profile saves in local catalog order, not click order. A new profile starts Empty or as a Copy of an existing profile. Two pills mark the rows: Active is the profile the gateway is running, and Selected is the persisted choice when it differs. You cannot delete either. The view leads with a "No profile" row that has its own Set Active. Each row's Set Active persists that selection immediately and raises the restart banner when a restart is needed; the selected row's button reads "Selected". When the state file names a profile the config no longer defines, the view shows a Stale pill with the missing name, and Set Active on any row replaces it.
The Profiles view shows an Estimated VRAM summary that sums declared model weights. Per-dominion budget rows warn at 80 percent and error when over. KV cache grows with context length, so 20 percent headroom is recommended.
Discover
The Discover view searches Hugging Face. The search box accepts keywords, a user/repo form, or a pasted hub URL, and keystrokes collapse into one search after a 300 ms debounce. The GGUF filter chip is locked on because the gateway serves GGUF inference only. Chat is the default workload filter; the filters cover Chat, Embedding, Reranker, STT, Image, and TTS. Result rows show the publisher avatar, a parameter-count pill, compact download and like counts, and a relative updated time. Sorts are Most downloads, Trending, and Newest.
A model's GGUF files are grouped into named quantizations with exact summed byte sizes and the LFS SHA-256 for single-file quants, listed smallest first. Each quant shows a fit badge computed against the gateway's system snapshot: Fits GPU, Partial offload, CPU only, or Too large. One Recommended star marks the largest quant that fully fits free VRAM. A multi-part GGUF cannot be downloaded as one model; the button is disabled with an explaining tooltip. You can read model cards in the view, rendered as sanitized HTML so embedded scripts and event handlers cannot execute.
A Download click stages a pending model entry carrying the hub resolve URL, the LFS digest, and the listing size as vram_gb; the transfer happens at the next boot. Staging a discovered model also adds it to the selected profile's checklist, so the restart after Apply provisions and serves it. When no profile is selected, the model is added to the catalog alone and a toast says so; choose it in a profile to run it. The staged entry prefills a mapped built-in chat template when the server-side catalog matches the repo. An STT-filtered download stages a first-class stt_model entry with the interim role. Without a configured HF token you see a banner linking to the Secrets view instead of search results.
Local and Remote
The Local and Remote views show the gateway's own catalog subsets: Local lists your local and speech-to-text entries, and Remote lists your remote entries. STT entries carry a Mic badge so you can pick them out at a glance. Filter chips narrow the list to All, Chat, or STT, a search box filters the rows after a short debounce, and a sort dropdown orders the list by Name, Size, or Kind.
Each model row shows a running-status dot, a kind badge, and capability pills. A quant badge read from the GGUF filename names the quantization, such as Q4_K_M. A model that exists only as a draft carries an "unsaved" badge until you save it.
Secrets
The Secrets view manages the one global .env file. Variables appear as masked password rows with per-row reveal and delete. Save stages a pending shadow that takes effect only after Apply plus a gateway restart. New variable names must use letters, digits, and underscores, and must not start with a digit. Each variable shows "used by" annotations naming the configuration entries that reference it. A dedicated Hugging Face card configures HF_TOKEN, and its Test Connection probes the token the running gateway holds and reports Not set, Valid, Invalid, or Connection failed.
Settings
The Settings view has seven sections: System, Gateway, Workshop, Dominions, Endpoints, Tools, and About. You land on System by default.
The System panel shows live metric tiles: CPU, RAM, VRAM with the GPU name, and disk usage with the cache path. The tiles refresh every 5 seconds, and a failed refresh keeps the last snapshot. Metric bars recolor by load: warning in the 70 to 89 percent band and danger at 90 percent or more.
The Gateway card edits the bind address, the API key, and the Trust loopback connections switch. The switch is on by default and admits callers on this machine that present no key; its help text states the cost, that on a shared machine any other OS account can then use the gateway, and turning it off requires the key from every caller. A note says the boot configuration cannot hot-reload, and changing the API key warns that the new key will be required after restart. The typed key leaves the DOM once saved. Stored secrets render as a masked readout with a Change button; leaving the input empty keeps the existing key, and an Eye toggle reveals and re-hides the secret.
The Dominions and Endpoints cards show used-by chips that count dependents, and a delete confirmation names them. A local-kind dominion reveals the vram_gb budget field, and switching the kind to remote hides it. An endpoint binds to a dominion from a dropdown offering only remote-kind dominions plus None. The endpoint protocol dropdown is locked to openai, and the endpoint API key stays redacted through saves until Change reveals the input.
The Tools section configures web search with the provider locked to Brave and the defaults documented on the card. The Storage card edits the cache directory beside live cache-drive usage, with a warning that changing the directory does not move existing files.
The About panel shows the medallion, the baked version or "dev", and the Boost Software License link. The Config UI card reports the UI as compiled in by the config-ui feature, served on the gateway's own port, loopback only, with the URL derived from the bind. The Workshop card edits the [workshop] section's one live content, the STT capture tuning - the gateway hosts no workshop listener, so the section's old bind and open_browser settings are inert and stay out of the editor. Adding the tuning seeds window_seconds 15, interval_ms 500, and an empty vocabulary.
Editing a model
You edit a local model through sections for GPU, generation, source, and capabilities in the model detail view. An unconfigured optional section offers an Add button. The chat template control offers Auto, a built-in template family, or a custom .jinja path, with a read-only summary naming the effective source, the detected family, and the reason.
The model name edits inline in the detail header, and the header shows the model's status: Unsaved, Running, or Stopped. Each edited field carries a dirty dot and a per-field reset. Each saved-but-unapplied field carries a pending chip whose tooltip shows the running value. Deleting a model confirms a dialog naming the model and every affected profile, and the save removes every dangling profile reference in the same payload. A downloaded model shows its cached size and path with a Delete file action. A path source gets a reveal-in-folder button; URL sources get none. Capability pills show images and thinking mode, and the images pill is implied and locked when a multimodal projector is configured. The gpu_layers slider readout carries the GGUF layer total, and typing "Max" maps to the maximum.
The controls follow the shape of the value. Numeric settings pair a slider with a typed readout, typed values clamp to the allowed range, wide-range settings such as the context window use a logarithmic scale, and some sliders offer a rightmost "Max" detent. List-valued settings such as a model's endpoint list are edited as removable chips. Fields with a fixed choice set accept only the listed values. Boolean settings use an on/off switch. A setting can be disabled until a sibling field holds a required value, or hidden until a predicate passes, so you only see applicable controls.
Retyping a field's original value clears its unsaved edit, and you can reset one field or a whole entry. A new model entry starts as an unsaved draft, and name collisions get auto-suffixed. Every settings save carries the complete single-file configuration, so one section's save never erases another staged section.
An orphan section lists unconfigured files on disk with Adopt and Delete actions per file; Delete is disabled when the file has no verified digest. The UI shows whether a model's source file is already downloaded. On gateways built without local-model features, missing orphan and chat-template endpoints degrade to empty lists instead of breaking the UI. You can restore the recommended speech-to-text model pair, digest-pinned, over the existing STT catalog entries from the UI.
Panel mode
The configuration UI runs in two modes. Standalone mode runs in a browser tab. Panel mode embeds the UI inside the Workshop with ?mode=panel. In panel mode your API key never enters the frame; every gateway call rides a postMessage bridge to the Workshop, and the panel only talks to a loopback workshop origin. Bridged calls fail after a 30 second reply deadline rather than hanging. Apply and Revert actions are announced to the workshop's status bar, and the workshop pushes its theme and an initial route into the embedded panel once the bridge is up.
Serving and Observing
This chapter teaches you the running gateway: the HTTP endpoints it serves, the tools it can host, and the health, logs, and observability surface you operate day to day. You already run a configured gateway with a profile and its models.
Web search
Enable the built-in web-search tool with a [tools.web_search] section:
[tools.web_search]
provider = "brave"
api_key = "${BRAVE_API_KEY}"
default_count = 10
max_count = 20
max_per_host = 2
strip_tracking = true
The provider is locked to brave. The base_url defaults to the Brave Search endpoint and must be an HTTP(S) URL. The default_count must not exceed max_count. Freshness and safesearch defaults are closed vocabularies, not free text. The gateway calls the Brave Search API at {base_url}/web/search with the configured API key sent in the X-Subscription-Token header.
Callers run a web search through POST /v1/tools/web_search. The request body carries a query and optional count, freshness, country, search_lang, safesearch, include_domains, and exclude_domains. Unknown fields are rejected. The query is trimmed, rejected when empty, and capped at 512 characters. Caller knobs are validated before any provider call: freshness must be pd, pw, pm, py, or a date range; safesearch must be off, moderate, or strict; country is a 2-letter code; the search language is a 2 or 3 letter code; each domain entry must be a bare valid domain. The count defaults to default_count and clamps into 1 through max_count. The gateway over-fetches up to three times the requested count, capped at max_count, so post-processing filters still yield enough results. Omitted freshness and safesearch fall back to the configured defaults.
Results carry title, url, site_name, and extra_snippets. Result text is sanitized and capped, results are diversified by host at max_per_host, and a result whose URL is not navigable or is over 2048 characters is dropped. When strip_tracking is on, known tracking parameters such as utm_*, fbclid, gclid, mc_cid, and mc_eid are removed from result URLs. Include and exclude domain lists match the host itself or any subdomain.
When no [tools.web_search] section is configured, the route answers 404. The route exists only in builds compiled with the web-search feature. Search provider failures surface with a web_search: prefix on the error, so you can distinguish search upstream errors from other gateway errors. The search service is built from the [tools.web_search] section and is replaced live when an applied edit changes it. The provider credential never appears in logs.
The deprecated [workshop] section
The gateway never hosts the workshop: the desktop application embeds the workshop server itself, and the standalone workshop-server binary serves the UI for a browser. A boot config carried over from an older version may still declare a [workshop] section with the inert bind and open_browser settings, which produce a deprecation warning at startup. Speech pipeline tuning belongs in [stt]; legacy [workshop.stt] input is rejected as an unknown workshop field whether it appears alone or beside [stt].
Manage the cache
Manage the blob cache through the gateway's cache routes. GET /v1/cache lists entries with source URL, path, SHA-256, and size. Only blobs carrying a .meta.json sidecar appear in the listing, and listing reads the sidecar metadata only; it never re-hashes the blobs. POST /v1/cache downloads a blob with an optional pin and streams progress events ending in a ready event. DELETE removes one blob by digest. Cache downloads validate the source URL and the pin before any network access. A cache download lands in the same slot layout that local model provisioning uses, so a cache download is a provisioning cache hit for the same URL, and vice versa.
GET /admin/orphans lists cache files that no [[local_model]] or [[stt_model]] declared in the catalog references, so leftovers can be adopted or deleted. GET /admin/model-info reports a GGUF file's header summary (architecture, layer count, parameter count, and chat template) without loading the model; only files inside the artifact cache can be inspected, and escaping or missing paths are refused. POST /admin/reveal opens the host's file manager at a model or config file; reveal requests are confined three ways: loopback-only, bearer key required, and the path must canonicalize to strictly inside the artifact cache.
The gateway restricts the cache root to your own account at startup and refuses to run when it cannot, failing with a cache-not-private error.
Status, progress, and metrics
GET /admin/status reports the running profile (null when the gateway booted with no profile), the local and speech models that profile lists as model_allowlist, the models the gateway exposes, and a config generation that changes when the gateway restarts. It also reports the command queue: the active command's name, progress fraction, and start time, plus the pending commands, so the boot load and applies are visible while they run. With the STT feature it also includes generic speech facts: whether speech is configured, whether the boot-time engine load has completed and speech is ready, and whether its backend reports GPU acceleration. A featureless build omits the speech object. GET /admin/profiles lists the profiles in the loaded catalog.
GET /admin/progress streams every long-running operation in the process as one server-sent event stream. A fresh subscriber first receives live operations replayed, then every event. Heartbeat comment lines arrive every 15 seconds while idle.
Download progress renders as tracing log lines on every stream.
GET /admin/system reports host metrics: CPU, RAM, the cache drive, and the first NVIDIA GPU's VRAM. The GPU field is absent, never an error, when no capable driver is present. You can also pull bounded captured stdout and stderr tails for each running local model as diagnostics.
GET /admin/chat-templates returns a bearer-authenticated catalog of chat template families, known model-to-family mappings, and each pending local model's effective template decision.
You can search Hugging Face and read model details and READMEs through the gateway's hub proxy. A missing or invalid HF_TOKEN surfaces as a distinct "set HF_TOKEN" error. Hub search queries are validated against a closed allowlist before any upstream call, and repository paths must be an exact owner/name pair of hub-legal segments.
Errors and limits
Every request failure reaches the client in the OpenAI error envelope: an object with message, type, and code under error, with a stable HTTP status. Examples: 401 unauthorized, 404 model_not_found, 400 malformed_request, 400 kind_mismatch, 429 queue_rejected, 503 queue_full, 503 model_loading, 503 partial_start, 422 config_write_rejected, and 422 model_info_error.
Outbound calls to any backend have fixed timeouts: 10 seconds to connect and 120 seconds for a whole non-streaming request. Streaming connections are bounded only by the connect timeout. Response bodies the gateway reads are capped: 64 KiB for error bodies and 4 MiB for success JSON bodies.
Malformed client requests are rejected at the boundary. An empty model name, an empty messages array, an unsupported message role, or a message with neither content nor a tool call all fail validation. Request fields the gateway does not name pass through to the backend verbatim, while the reserved keys model, messages, and stream may not be smuggled in twice. Embeddings requests accept one string or a batch of strings, with an optional encoding_format of float or base64; an empty batch is rejected. Rerank requests carry a query, a document set, and an optional top_n limit; an empty query or document set is rejected.
Reading failures
The error code distinguishes a connection that never reached the provider from a mid-flight failure. The first is safe to retry; nothing was billed. The second is not safe to retry blindly. A backend's own client-error status, for example 429, passes through to the caller with code upstream_client_error instead of a generic 502. A model of the wrong kind is refused with 400 kind_mismatch before any upstream call. A request for a workload the resolved model cannot serve is rejected with 400 model_unavailable.
When the gateway recovers from a malformed tool fence in an emulated tool dialect, the response message carries a gateway_warning extension field. The turn never fails, and protocol junk never appears as final text. Streaming clients still receive tool calls from an emulated-dialect model: the gateway buffers one upstream round trip and re-emits the rewritten response as synthetic chunks, with a trailing summary chunk carrying usage and timings.
A malformed upstream stream chunk is logged and skipped without ending the stream. A mid-stream transport failure ends the stream with an error. An upstream error status fails a streaming request before any chunk is delivered, returned as a JSON 502, never as a stream that dies mid-flight. A client disconnect cancels the upstream request.
The Prompt Language
- Frontmatter and Structure
- The Run
- Sections and Blocks
- Lua Globals and the Store
- Prose Substitution
- Models
- Tools
- Control Flow
- Limits and Errors
- Fanout
Frontmatter and Structure
A PromptForge prompt is one markdown file, and this chapter teaches you how that file is put together: the frontmatter block at the top, the title, and the sections that divide the body. It is worth learning first because the parser checks the whole shape before anything runs, so a prompt that gets the structure right never fails halfway through a run.
The smallest complete prompt
Here is a complete working prompt:
---
name: greeter
description: says hi
promptforge: 0
---
# Greeter
## Say hi
Say hello.
Every prompt has this skeleton. A frontmatter block opens the file, a level-1 heading titles the prompt, and level-2 headings divide the body into sections.
The frontmatter block
The file must begin with a --- delimiter line, and a second --- line closes the frontmatter. Between the delimiters you write YAML with three keys: name for the prompt's name, description for a short summary, and promptforge for the format version.
The promptforge key is what makes the file a promptforge prompt at all. A file without the key is not one, and the runtime refuses an unsupported major version before anything runs. This build supports major version 0, so write promptforge: 0.
The parser is strict here. A leading UTF-8 byte-order mark is dropped. Malformed YAML fails the parse and preserves the underlying cause. Unknown or misspelled keys are rejected at parse time rather than silently ignored, so a typo such as desciption: fails loudly instead of being skipped.
The contract keys
Four optional frontmatter keys declare what the prompt needs from its host. Together they form the prompt's contract, and the host satisfies it before anything runs (see The Run):
capabilities:lists the capabilities the prompt activates, by global id. A capability id has exactly two segments,namespace/pack. A bare id declares a required capability; the map form,{ ref: namespace/pack, optional: true }, declares one the run skips when absent, and may carry prompt-sideconfigdata. See Tools.tools:declares the run's tool slots, keyed by a prompt-local alias. A bare string is an exact global tool path (namespace/pack/name, exactly three segments); the map form,{ want: "prose description" }, is a fuzzy slot filled at prepare. See Tools.models:declares the run's model roles, keyed by a prompt-local label, each with a keyword set, an optionalmin_contexttoken floor, and a description. See Models.args:declares the run's typed input fields, each with atype(string,boolean,integer, ornumber), anoptionalflag, an optionaldefault, and a description. A prompt with noargs:key gets the default declaration: one optional string field namedprose. See Lua Globals and the Store.
Aliases, labels, and arg names share one grammar: [A-Za-z][A-Za-z0-9_-]{0,63}. These names are prompt-local; the model only ever sees them, never a global path. The parser's strictness covers the contract keys too: a malformed capability id, an unknown model keyword, or an arg default whose type differs from its declaration fails the parse with the position named.
Declaring input and output files
Two optional frontmatter keys declare the store files your prompt works with. The input: key names a file the prompt expects at start. The output: key names a file it leaves at finish. Each declaration pairs a store-internal path with a human-readable description that documents the file's role.
The title
After the frontmatter comes the title: a single level-1 heading. The prompt must contain exactly one H1, and it must not be empty.
Anything written between the frontmatter and the H1 is preface. Preface has no prompt semantics, so use it for notes to human readers, never for instructions.
Sections
Level-2 headings divide the body into named sections. Sections nest one level at a time: an H3 sits under an H2, an H4 under an H3, and so on through H6. A heading that skips a level, such as an H4 placed directly under an H2, is rejected as an orphan.
Sibling section names must be unique. Two siblings with the same name are rejected, and the error names both declaration line numbers so you can find the collision. The same name under different parents is allowed, because the nesting path differs.
The shared library fence, structurally
One last structural rule. A prompt allows at most one lua shared fence, and only inside the H1 body. A second one, or one placed inside a section, fails the parse. The older lua prompt fence form was removed; writing it fails with a fence error that names the two valid forms, lua and lua shared. The placement rule is structural: the parser enforces it before anything runs, before the fence's contents ever matter.
The Run
You can now write a well-formed prompt file, so the next question is what happens when it runs. This chapter walks a run from beginning to end: the prepare pass that satisfies the prompt's contract, the live pass over the title, the ordered walk through the sections, how results appear, and how a run finishes. Once you can picture a run, every other feature of the language has a place to attach.
Prepare: satisfaction before the walk
A prompt never binds its own models and tools; it declares them, and the host satisfies the declaration before the run begins. When you run a prompt, the host first prepares the run against its environment: it activates each declared capability, assembles the catalog of tools those capabilities contribute, and fills every declared slot - each model role bound to a concrete model, each tool slot bound to a concrete tool. Every fill is journaled, so the host can show you exactly what a fuzzy want resolved to.
Prepare then checks the declaration against what the environment could satisfy and reports what still needs human attention: model requirements the filled model does not meet (a min_context above the model's context window, or a hard keyword its descriptor contradicts), required capabilities that are missing or failed to activate, and declared capability pairs that cannot activate together. When the report is clean the run begins. When it is not, the run fails before the walk with a notice naming each gap, required versus actual.
The preamble
When a run starts, the H1 section's Lua and prose blocks run first, in a live pass with full host access. This pass is the prompt's preamble. Binding is already done - prepare filled every declared role and slot - so the preamble arranges the run's own affairs: models.default parks a declared role as the prompt-wide default, tools.always advertises a bound tool in every section, and the H1 pass is the one place argv is writable, so a prompt that repairs malformed input does it here (see Lua Globals and the Store).
A prompt with only an H1 title and no sections still runs. And a scalar return from the live H1 pass short-circuits the whole run: the returned value becomes the run's result, and no section ever fires.
Four calls are unavailable from the preamble: call, jump, fanout, and list_from_section. Each fails with "only available in sections". These calls move control between sections, so they exist only once the section walk has begun.
The section walk
After the preamble, the top-level sections run in file order. The first H2 section in the file is the entry point, and control falls through from each section to the next.
Each section runs in its own isolated, sandboxed Lua state. Only the string, table, and math standard libraries plus safe base functions are available. The state is created at section entry and torn down at exit, so one section's Lua cannot leak into the next.
A section that talks to the model needs a model. models.use selects a declared role by its label for one section, and the prompt-wide default covers sections that select nothing; a model-facing call with neither fails with a model-required error. Tools follow the same pattern: tools.always or tools.add advertise a bound tool to the model under its local alias.
Lua blocks and prose blocks
The content of the H1 and of each section is an alternating sequence of lua fences and prose blocks. The classic shape is prologue, prose, epilog: a Lua block, then a prose block, then a Lua block.
Prose is data, not an implicit model turn. The prose written between a heading or Lua block and the next Lua block accumulates as a pending buffer, and that following Lua block reads it as the lazy prose value. Nothing is sent anywhere until Lua says so: models.infer(prose) runs one model round over it, and messages.new() plus models.loop builds a full conversation around it. The {{ }} placeholders in the buffer are substituted on the first read of prose, not before. Prose no Lua block ever reads is inert commentary, and prose left after a section's final Lua block is discarded when the section ends.
Returns and the run result
A scalar return from a section's Lua block ends the run early with that value. When the first section returns "first", a later section's own return "unreached" is never reached. A run in which no section returns finishes with the generic completion "done".
The host sees one of three outcomes. A completed run yields its final text. A cancelled run reports cancellation distinctly, so an interrupted run is never mistaken for a failed one. A failure carries a typed error whose kind classifies the fault - parse, binding, completion, tool, and so on - with a message written to be read and, when the failure has a source position, the prompt name and line to navigate to. Domain outcomes, including the prompt declining to answer, are ordinary result text, not failures.
What carries between sections
Sections are isolated in Lua, but three things roll forward through the walk. The var table is a per-run clipboard: it is seeded into each section's Lua state on entry and read back before teardown, so the next section sees the updates. The run-scoped store persists bulk state as virtual files addressed by logical string paths, shared across every section of the run. And everything else moves explicitly: call(heading, input) hands a subroutine its input and returns its result, so the author chooses what crosses a section boundary.
Moving control between sections
Fall-through is only the default. A running section can also call call(heading) to run another section as a contained chain and get its return value back, jump(heading) to transfer control outright, and fanout(worker, collection) to run a worker section once per collection member concurrently. For now, hold the picture of the walk: preamble first, then sections in file order, with the clipboard, the store, and explicit call results rolling forward.
Sections and Blocks
You have seen the run from the outside. This chapter goes inside a section and teaches the pieces you write there: the exact fence forms, the thematic break that resets prose capture, list sections, and the shared library. These are the parts you touch in every prompt, so it pays to learn their exact shapes now.
The two fence forms
A Lua block opens with an exact, unindented fence line. Only two forms are valid:
```lua
var.greeting = "hello"
```
```lua shared
function shout(s)
return s:upper()
end
```
The marker is recognized only as an exact, unindented opening line. Near-miss forms, and marker-looking lines nested inside longer code blocks, stay in prose. An unclosed fence is a parse error that names the phase. The removed lua prompt form fails with a fence error naming the two valid forms.
The shared library
One lua shared fence in the H1 body defines a shared library. It is replayed as every section's first chunk, so its functions and globals are available in every section and every fanout arm. Because each section gets its own Lua state, the shared library is how you give every section the same helpers without repeating them.
The thematic break
A --- rule inside a section is a thematic break, and it does exactly one thing: it resets the pending prose buffer. Prose above the break is excluded from the next Lua block's prose value, and only the Markdown below the break is captured:
## Draft
Working notes the model should never see.
---
Write the actual instructions here.
```lua
return models.infer(prose)
```
A break carries no control-flow meaning. It never ends a section, skips a section, or stops a call, and everything below it - Lua fences included - parses and runs normally. Use breaks to keep commentary inside a section without letting it leak into prose.
One formatting rule matters here. A blank line must precede a --- rule. A prose line directly followed by --- parses as a setext heading underline, not a rule, so Some prose immediately followed by --- becomes a new section named Some prose.
List sections
A section with no Lua blocks whose every nonblank prose line is a list marker is a list section. Its items are pre-parsed at load time:
## Topics
- alpha
- beta
The item markers are - , * , N. , and N) . Blank lines are ignored. Empty items, non-list content, and empty list sections are parse errors.
From Lua, list_from_section(heading) returns a visible list section's items as an array of strings, with the bullet and number markers stripped. Over the list above, list_from_section('## Topics') yields the strings alpha and beta.
Naming a section exactly
Calls such as jump, call, fanout, and list_from_section take a heading reference. Write it exactly: one or more # markers, whitespace, then a non-empty name. Forms like ###Name with no whitespace, or a bare name with no markers, are rejected, so a malformed heading can never be silently reinterpreted.
The frozen preamble
Remember that the H1 pass runs first with full host access. The tool and model bindings declared there become structurally frozen for the rest of the run: once the preamble's Lua state is gone, nothing can add or change a binding. Sections select from what the preamble declared; they do not declare their own.
Lua Globals and the Store
Every section runs sandboxed Lua, but it does not run empty-handed. This chapter teaches the globals the runtime seeds into each section, args and argv, sys, var, prose, and log, plus the run-scoped store where a prompt keeps its bulk state. These are your everyday tools, so we take them one at a time.
args and argv: the run's input
Every section's Lua block can read the run's exact argument string through the args global:
log('the run was started with: ' .. args)
The argv global is the parsed form of that string, shaped by the prompt's args: declaration. A prompt with no args: key has the default declaration - one optional string field named prose - and the interface wraps the argument string into it, so argv.prose reads the input text on every channel, the empty string included. A prompt with a structured args: declaration receives its argument string as JSON: the call argument {"query": "papers", "limit": 5} arrives as a table with argv.query and argv.limit. When the string does not parse as JSON, or parses as null, argv is nil, so if argv then is the idiomatic malformed-input check.
Optional means absent. A call that omits an optional field leaves argv.field nil; absent is not the empty string, and a present empty string is a real value the caller chose to send.
The H1 repair pattern
The argv global is writable in the H1 pass and frozen everywhere else. A prompt that tolerates malformed input reads the raw args, computes the repair, and assigns it:
if not argv then
argv = { query = args }
end
The executor reads the value back when the H1 pass completes, and every later section sees the repaired value frozen: reads work, absent fields read nil, and any assignment - argv = ... or a field write at any depth - fails with an error naming the freeze.
sys: runtime metadata
Every section receives a sys JSON value carrying when, now, id, section_name, execution, and section_count.
The sys.when and sys.now values are the current UTC time formatted as RFC 3339 strings. The when value is stamped once at the walk's start, so every section agrees on when the run began, while now is fresh at each read.
The sys.id value is a run-global counter. The H1 pass keeps id 0, and every section entry and every fanout arm takes the next value, so entering the same section twice yields two distinct ids.
One field is conditional. sys.index exists only when the section runs as one arm of a fanout, a concurrent walk over a collection. Reading it in an ordinary walked section raises an unknown-field error. Arms of a nested fanout restart sys.index numbering at 1.
Once the section has dispatched its first model or tool call, sys.model reads the catalog id of the model the section resolved. Reading it before that first dispatch raises an unknown-field error.
log: checkpoints
Call log(...) from any section's Lua block to emit a checkpoint. Checkpoints are reported through the run's observer under the current section name, which makes them the simplest way to trace a run.
var: the per-run clipboard
The var table is a per-run clipboard. It is seeded into each section's Lua state on entry and read back before teardown, so the next section sees the updates:
var.topic = 'governance'
Two rules keep the clipboard safe. Reassigning the var global itself fails the run; you mutate its fields, never replace it. And assigning a non-JSON value to a field fails, naming the field and the type: var.f = function() end errors because a function is not JSON data.
prose: the pending Markdown
The prose written since the section's heading or last Lua block is available to the next Lua block as the prose global. It is lazy: the {{ }} placeholders in it are substituted on the first read, not at block entry, so a block that never reads prose never evaluates it. The value is read-only and memoized - assigning to it fails, and every read after the first returns the same substituted text. Each prose buffer is fresh: a second prose block in the same section evaluates independently for the Lua block that follows it.
local answer = models.infer(prose)
store: virtual files
The run-scoped store persists bulk state as virtual files addressed by logical string paths, shared across every section of the run. The core operations read and write whole files:
store.write('state.txt', 'first')
store.append('state.txt', '\nsecond')
local text = store.read('state.txt')
if store.exists('state.txt') then
log('state is present')
end
The call store.write(path, text) writes a virtual file, store.append(path, text) appends to it, store.read(path) returns its verbatim contents, and store.exists(path) returns true when a store file is present.
Three more operations help with larger files. The call store.read_numbered(path) reads a file with absolute 1-based line numbers attached. Both store.read and store.read_numbered accept optional 1-based start and end line numbers that select a range, so store.read_numbered('a.txt', 84, 85) returns only lines 84 to 85, numbered. And store.glob(pattern) lists store entries matching a wildcard, as in store.glob("ready-*.md").
untrusted: guarding re-injected content
When store content goes back to the model, wrap it first. The untrusted(text) global wraps store content in a guard envelope before it is re-injected, so the model treats it as data rather than instructions.
Designed, not yet built: the prompt global
A prompt reflection global is designed but not yet built. It will expose the prompt's own declaration to section Lua - the declared model roles, tool slots, and args - so a prompt can adapt its behavior to how it was satisfied. Today the declaration is visible to the host that runs the prompt, not to the prompt's own code.
Prose Substitution
Prose blocks are not static text. When a Lua block reads its prose value, {{ }} placeholders in the pending Markdown are replaced with live values from the run. This chapter teaches the substitution language: the namespaces, the path syntax, the escapes, and the errors. It is a small language, and learning it well keeps your prompts honest, because substitution never computes anything.
The namespaces
Each placeholder names a namespace and, for most of them, a key:
{{ args }}inserts the run's input string, exactly as passed.{{ argv }}inserts the parsed form of the input as compact JSON, and{{ argv.key }}indexes into it.{{ item }}inserts the current member when the section runs as an arm of a fanout.{{ var.key }}inserts a field of thevarclipboard.{{ sys.key }}inserts runtime metadata.- A bare name, such as
{{ kind }}, inserts a section-local Lua global.
So hi {{ args }}! with the run argument Acme Corp reads as hi Acme Corp!.
The args and argv namespaces are two views of one input. {{ args }} is always the exact string the run was started with. {{ argv }} is its parsed shape under the prompt's args: declaration (see Lua Globals and the Store): {{ argv }} renders the whole value as compact JSON, and a dotted path such as {{ argv.query }} indexes into it.
Dotted paths and structured values
Dotted paths index into nested values. With var.row = { a = 1 }, the placeholder {{ var.row.a }} renders 1. A placeholder that resolves to a whole table or array renders as compact JSON, so {{ var.row }} renders {"a":1}.
Escapes
To emit a literal {{, }}, or backslash, escape it with a backslash. The text \{{ args }} renders as the literal characters {{ args }}.
One pass, no arithmetic
Substitution is a single pass over prose only. Replacement output is never rescanned, so a substituted value that happens to contain {{ }} stays literal. No arithmetic is performed: compute in Lua, keep the result in var or a global, and reference it. Lua source is never substituted.
Substitution is also lazy. It runs on the first read of prose, not at block entry, so a placeholder that would fail costs nothing in a block that never reads its prose.
Hard errors
Substitution failures are ordinary Lua errors raised at the read site, with specific messages, so a block can catch them with pcall. The failures cover an unknown namespace or global, a missing key, a null value, a bare {{ var }} or {{ sys }}, dotted indexing into a string, an unclosed {{, empty path segments, and non-JSON globals.
Two placeholders have preconditions. Using {{ item }} outside a fanout arm is an error because no collection member exists. And using {{ argv }} or any {{ argv.key }} path when the input did not parse - a nil argv - is an error, never a silent empty string.
How item renders
Inside a fanout arm, {{ item }} renders the current collection member by type. Strings render verbatim. Numbers and booleans render in natural string form, so 1.5 renders as 1.5 and true as true. Arrays and objects render as compact JSON.
Models
A prompt does not name a model directly. It declares the roles it needs in the frontmatter, and the host binds every role to a concrete model before the run begins. This chapter teaches the declaration, the two calls that select among bound roles, models.default and models.use, plus the two operations that run model rounds from Lua, models.infer and models.loop. Declared roles are what keep a prompt portable across catalogs, so it is worth learning as a habit from the start.
Declaring a role
Declare a model role in the frontmatter with the models key:
models:
analyst:
keywords: [no-thinking]
min_context: 40000
description: careful analysis
Each key is a prompt-local label. A role carries a keyword set, an optional min_context token floor, and a description.
The keyword vocabulary is closed, and split in two. The hard keywords, thinking and no-thinking, are checked at prepare against the filled model's descriptor, as is the context minimum: a role requiring min_context: 200000 filled with a 32k model, or requiring thinking filled with a model that never thinks, is reported as an unmet requirement naming the role, required versus actual. The soft keywords - frontier, fast, small, creative, and chat - document author intent for the day a smarter fill can shop for them. An unknown keyword fails the parse; adding a keyword is a language change.
Today's fill is deliberately trivial: every declared role binds to the host's current model (in the Workshop, the dropdown's selection). The declaration is written for the full contract - roles, requirements, checks - so the same prompt runs unchanged when a smarter fill arrives; only the binding decisions change.
The default model
The call models.default designates the prompt-wide default, parking a declared role by its label:
models.default("writer")
The label names a role declared in the frontmatter models key, and an unknown label is a hard error, because every label must be declared. Naming the same label again is a no-op, so a shared library replayed into every section may name the default; naming a different label fails, because the prompt-wide default cannot change mid-run.
Selecting a model for a section
Inside a section, models.use('analyst') selects a bound role by its label for that section. The selection is read when a model round starts, so a later models.use call in the same section replaces it and steers the next round. A section that runs a model round needs a model from models.use or from the prompt-wide default; with neither, the call fails with a model-required error.
Inspecting a binding
Every bound role is also a bare global holding an inspectable handle, and models.get(label) returns the same handle, with name, label, capabilities, model_id, description, context, thinking, temperature, and max_tokens fields. Reading a handle does not change the section's selection. Handles are plain values: they have no methods, and every operation that accepts one takes it as a leading argument.
Direct inference
Sometimes you want one quick model round over a prompt string, most often the section's prose. The call models.infer(prompt) runs one tool-free inference round on the section's current model, and models.infer(handle, prompt) runs the same round on the handle's frozen binding:
local greeting = models.infer(prose)
local second_opinion = models.infer(models.get('analyst'), prose)
A handle from models.get can run infer even when the section has no model selection at all, while the handle-less models.infer fails in that case, naming the section. Neither form advertises tools or touches sys.
Two edge cases are worth knowing. An infer round that hits the model's length limit is reported as truncated while still returning the text produced so far. And if the model answers an infer round with tool calls, the run fails, because no tools were advertised.
The model loop
For anything beyond one round - tool use, a retained conversation, a model that drives - models.loop(messages, compactor?) runs the Rust-backed model-tool loop over a message list you own:
local msgs = messages.new()
msgs:system('You are a careful researcher.')
msgs:user(prose)
models.loop(msgs)
local answer = msgs[#msgs].content
The loop reads the section's current model selection and tool scope at call time. With no tools in scope it performs exactly one model call. When the model emits structured tool calls, the runtime dispatches them, appends each assistant message and correlated tool result to your list, and continues until the model answers with terminal text, which is appended as the final record. The loop returns nil; the list is the result, and you may remove the terminal record explicitly when you do not want it. The optional leading-handle form models.loop(handle, messages, compactor?) runs the loop on the handle's frozen binding at any point in the section.
When a request would overflow the model's context window, the loop invokes the compactor you pass, or compactors.fail when you pass nothing. The compactors.fail policy always raises a typed context-exhausted error; replacement strategies are a later addition to the language.
Building message lists
The messages.new() builder returns an ordinary numeric array of message records with chainable system, user, assistant, tool, and append methods, so msgs:user('hi') appends { role = 'user', content = 'hi' } and returns the same list. The array stays plain data: you can write records by hand, mix both styles, and pass either to models.loop.
Reading which model answered
The field sys.model is not readable from Lua before the section's first model or tool dispatch; reading it earlier fails with an unknown-field error. After that first dispatch it reads the catalog model id, not the alias, and {{ sys.model }} in later prose renders the same id.
Environment variables
A run that needs an environment variable that is not set fails with an error naming the missing variable. A variable that is set but holds a non-Unicode value is a distinct failure.
Migrating from models.bind
Earlier versions bound models from Lua, resolving a prose description against the catalog at run time. The declaration moved to the frontmatter, and binding moved to prepare. Before:
models.bind('analyst', 'a careful model that does not think')
models.default('analyst')
After:
models:
analyst:
keywords: [no-thinking]
description: careful analysis
models.default('analyst')
The models.bind call is removed. What was its prose description now documents the role, the hard requirements ride keywords and min_context, and models.default and models.use name declared labels only.
Tools
Models reach the outside world through tools, and a prompt controls exactly which tools the model can see. Tools arrive in capabilities, the installation unit, and a prompt declares the capabilities it activates and the tool slots it binds in the frontmatter; the host fills every slot before the run begins. This chapter teaches the declaration, the advertising calls tools.always and tools.add, local tools written in Lua, direct dispatch with tools.call, and the failure modes you will meet. Tool scoping is the prompt's main safety surface, so we build it up one idea at a time.
Capabilities and global names
A capability is the activation unit: code that runs at run setup and contributes tools. Every capability has a global id of exactly two segments, namespace/pack, where the namespace is a reverse-DNS name such as io.github.corp or the reserved first-party prefix promptforge. Every tool has a global path of exactly three segments, namespace/pack/name, and a tool's first two segments always name the capability that contributed it: promptforge/web/fetch comes from the promptforge/web capability, no exceptions.
Declare the capabilities a prompt activates with the capabilities key:
capabilities:
- promptforge/web
- ref: io.github.corp/vault
optional: true
A bare id declares a required capability: when it is absent from the host's registry or fails to activate, the run cannot start, and the preflight report names it. The map form with optional: true declares a capability the run skips with a log line when absent, so one prompt runs with or without an enhancement; the optional config key carries prompt-side data to the capability. User-specific configuration such as credentials is host-supplied and never named in the prompt.
Declaring a tool slot
The tools key declares the run's tool slots, keyed by a prompt-local alias:
tools:
search:
want: search the web
fetch: promptforge/web/fetch
A bare string is an exact global path, filled by identity against the assembled catalog. Since the path's first two segments name its capability, a slot whose capability is not active cannot fill, and the preflight report says so. The map form is a fuzzy slot: the want prose is matched against the catalog at prepare by the picker, a local sentence-embedding model that maps English descriptions to tools, and every fill is journaled so you can see what the fuzz resolved to. A fuzzy slot with optional: true skips with a log line when nothing fills it.
Binding versus advertising
Binding and advertising are separate facts. Binding is decided entirely at prepare: everything a binding decision could depend on - the frontmatter, the active capabilities, the assembled catalog - is known by then, and the journaled result is the run's bindings, alias to tool. What remains for run time is advertising: the prompt's Lua decides per section which already-bound aliases the model gets to see. The model only ever sees the alias, never the global path.
Advertising a tool to the model
Two calls advertise a bound tool under its local alias. The call tools.always('search') advertises the tool in every section, conventionally from the H1 preamble. The call tools.add('search') advertises it in the current section only. To advertise several bound aliases at once, pass an array:
tools.add({"search", "fetch"})
The array form takes no per-element overrides.
You can replace the description the model sees. The call tools.add(alias, override) takes an override, and tools.always accepts the same override as a trailing parameter. Precedence is the tools.add override over the tools.always override over the tool's catalog text.
Each bound slot is also a bare global holding a frozen Tool object with name, description, parameters, wire_name, and untrusted fields, and tools.add accepts Tool objects as well as alias strings. Because tools.always records a prompt-wide fact in state every section shares, naming the same alias again is a no-op, so a shared library replayed into every section may name it.
The tool loop
The tool loop lives inside models.loop. When the model answers a loop request with structured tool calls, the runtime dispatches each call to a tool in the section's scope, appends the correlated results to the message list, and asks again, until the model replies with terminal text. The scope is read at call time, so a tools.add earlier in the same Lua block applies to the models.loop call that follows it.
Calling tools.add with an alias that no frontmatter slot declared fails the run loudly. A model that calls a tool outside the section's advertised scope fails with an error listing the in-scope aliases, and the error notes when the alias was declared but not added to this section's scope.
Local tools
You can write a tool in Lua with tools['add_local']:
tools['add_local']('grab', 'Grab a value', { value = 'string' }, function(args)
return 'got ' .. args.value
end)
The handler runs as a Lua function in the section's own state. The parameter table is rendered to the model as a JSON schema with required properties; each value is a bare type string or a {type, description} pair. The handler's returned string goes back to the model verbatim and trusted. The handler can use store and section-global variables, but it cannot call jump, and a handler error fails the run with the handler's message. A local tool alias cannot collide with a declared slot alias or with another local alias, and every tool schema advertised to the model is validated before it is sent.
The decision-tool recipe
When prose guidance should steer the run's shape - which sections to walk, which bound tools to advertise - do not ask the model for prose and string-parse the answer. Interpret the guidance into flags with a local decision tool in the H1 preamble:
tools['add_local']('decide', 'Record the verdict: one of use_mcp, no_mcp, or unspecified', { choice = 'string' }, function(args)
var.verdict = args.choice
return 'recorded'
end)
local msgs = messages.new()
msgs:user('Given these instructions, decide whether the private sources are needed: ' .. args)
models.loop(msgs)
The model's tool call lands in the Lua handler, which records the verdict where the walk can read it. Three rules keep the recipe honest. The choice set must include an explicit "unspecified" verdict, so a genuine abstention has a name. The no-call exit is handled in code: when the loop finishes without a call, var.verdict is simply unset, and the prompt treats that as abstention. And for weaker models that struggle with parameterized calls, the fallback is three no-arg tools, one per verdict, instead of one tool with a parameter. Decision-tool results are journaled like any tool call, so a replay consumes the recorded verdict rather than re-rolling it.
Direct dispatch and call counts
The call tools.call(alias, args) invokes any tool bound in the document directly from a Lua block, even one not advertised in the section, without widening the set the model can see. A tools.call with an alias that has no binding fails with an error listing every bound alias. A Tool object works in place of the alias, so tools.call(tool, args) dispatches a held object directly.
The counter tools.calls[alias] reads how many times the model has called a tool in the section. Reading it with an alias that was never declared is a hard error naming the bad key and listing the declared aliases. The counter records a call even when the tool errors.
Trusted and untrusted output
Output from a tool that marks its result untrusted is wrapped in a preface and nonce-tagged <untrusted_input_...> markers before the model sees it. Trusted tool output appends verbatim, and structured JSON output from a trusted tool resumes into Lua as a table. A bound tool's failure text is wrapped as untrusted whatever the tool's trust marking, so an error message from the outside world never reaches the model as trusted prose.
Validation and edge cases
Two semantic near-duplicate tools in one model-visible scope fail validation, with an error naming both aliases, both identities, and the similarity score. If you genuinely need both, isolate them in separate sections with per-section tools.add.
An empty final reply from the model fails the loop unless a tool call preceded it and the finish reason is stop. A length finish reason returns the partial text and reports truncation. A bound tool's own failure does not abort the loop: the error message arrives as the call's tool result, wrapped as untrusted input, so the model reads the failure and the run continues. Cancellation and every other dispatch failure still abort the loop, and a local tool's handler error still fails the run.
Migrating from tools.bind
Earlier versions bound tools from Lua, resolving a prose description against the catalog at run time. The declaration moved to the frontmatter, and binding moved to prepare. Before:
tools.bind('search', 'search the web')
tools.always('search')
After:
capabilities:
- promptforge/web
tools:
search:
want: search the web
tools.always('search')
The tools.bind call is removed. What was its prose description is now the fuzzy slot's want, filled by the picker at prepare with the fill journaled; an exact path fills by identity. The advertising calls, tools.always and tools.add, are unchanged.
Designed, not yet built
Two extensions are designed but not yet built. The open posture, tools: { open: true }, lets a prompt accept whatever capabilities the host arms the run with instead of declaring its own; the open key is reserved today, so writing it fails the parse with a message saying so. And the prompt-pack capability contributes a directory of prompts as tools, one tool per prompt: invoking the tool runs the prompt as a sub-run, and the sub-run's result text becomes the tool output. Both arrive without structural change to what this chapter teaches.
Control Flow
Fall-through runs sections in file order, but real prompts need to choose their path. This chapter teaches the two calls that move control, jump for transfer and call for subroutines, together with the visibility rules that decide which sections you may name. Learn the visible set first; everything else follows from it.
The visible set
A running section can name as the target of jump, call, fanout, or list_from_section only its visible set: its sibling sections at the same heading level, excluding itself, plus its own direct child sections.
Heading references resolve on an exact level-and-name match. Zero matches is a not-found error listing only the visible set. Two matches is an ambiguity error rather than a silent pick.
jump: transfer control
The call jump(heading) transfers control to a visible section, and the jumping section's remaining blocks never run:
jump('## Help')
store.write('seen.txt', 'should-not-run') -- never runs
A jump carries the var clipboard across: the target's Lua state is seeded with the jumper's final var. Nothing else crosses implicitly, so pass state through var or the store.
A jump to a direct child heading starts a child-level walk over the jumper's children under the same rules, and the parent walk resumes after the jumper when the child level exhausts.
call: a contained subroutine
The call call(heading) runs a visible section as a contained chain with a fresh Lua state, waits for it, and returns the chain's return value to the caller:
local summary = call('## Research')
The call clones the caller's var into the child chain and discards the child's writes when the chain ends, so a subroutine cannot disturb the caller's clipboard. An optional second parameter supplies an input string that overrides the run's args for the chain:
local summary = call('## Research', topic)
Recursion and failure
Nested call and fanout recursion is capped at 8 levels, counting the first call. Exceeding the cap fails the call.
Suspending host calls such as call, fanout, and models.infer deliver failures as ordinary Lua errors. That means you can catch them with pcall and continue:
local ok, result = pcall(call, '## Research')
if not ok then
log('research failed: ' .. tostring(result))
end
Limits and Errors
Every run operates inside budgets, and every failure arrives in a stable shape. This chapter teaches the limits you can set, the defaults you get, and the error vocabulary you will see when something goes wrong. Knowing the failure shapes in advance is what makes a prompt debuggable.
Capping the tool loop
The frontmatter key max_tool_iterations caps the round trips of a section's models.loop:
max_tool_iterations: 5
A model that keeps calling tools without converging stops after exactly that many round trips, and the run then fails with a tool-loop-exhausted error. The value must be a positive integer from 1 to 1000; zero, negative, and over-limit values are rejected at parse time.
Default budgets
A run ships with these default limits:
- 24 tool iterations per section
- 8-way fanout concurrency
- a 16 MiB model response cap
- 64 MiB of Lua memory per section state
- 1024 Lua log events per section state
- a 120 second request timeout
A Lua block that exhausts a host resource quota fails with a typed quota error naming the exhausted resource: log events, log bytes, or instructions.
The error kinds
A run failure is classified into one stable kind: parse, version, binding, completion, tool, store, lua, quota, context_exhausted, input, substitution, cancelled, or internal. The kind tells you which layer rejected the run before you read the message.
Parse failures carry a stable classification kind and, when known, the location of the offending region. Lua compile errors name the prompt region and map back to the original source line numbers, so the error points at your file, not at generated code.
Retrying and cancelling
Transient failures are marked retryable, so you can retry the run: transport errors, malformed responses, and backend failures with a 5xx status.
You can cancel a run with Ctrl-C. In-flight requests abort, and even an unbounded Lua loop stops, because an instruction-counting hook polls the cancel flag. The run ends with an "interrupted by Ctrl-C" error.
Fanout
Some work is the same task repeated over a collection: summarize each file, grade each answer, research each topic. The call fanout runs a worker section once per collection member, concurrently, and hands you one result per member. This chapter teaches the call, the shape of its results, and the isolation and failure rules that make concurrency safe. It closes the set because it uses everything before it: sections, the store, control flow, and limits.
The basic pattern
The common shape pairs a list section with a worker section:
local replies = fanout("### Worker", list_from_section("### Topics"))
This runs the worker once per item of the list section. The second parameter must be a collection. The retired two-string form errors and points at list_from_section, and numbers and booleans error as not a collection. The worker must be a worker template section, not a list section; naming a list section is a Lua error. Fanout over an empty collection is an error raised before any scheduling, because no work is likely a bug.
The collection
Fanout accepts any Lua table as its collection. The array part iterates in order first, then the hash part iterates in undefined order, with each hash member arriving as a pair table carrying item.key and item.value. Function members and table-keyed members cannot cross into an arm.
Inside an arm
Each concurrent run of the worker is an arm. Inside an arm, the current collection member is available as the item global and as the {{ item }} substitution seed, and the arm's 1-based position is sys.index. Arms of a nested fanout restart numbering at 1.
Results
Fanout results arrive in collection order, never finish order. Each result is a structured object with four fields: .ok, .text, .item, and .exhausted. Calling tostring on a result yields its text, so table.concat(results, ',') joins the texts directly.
An arm that produces no output yields empty text and still reports ok.
Isolation
Concurrency comes from interleaving chains at I/O points, not from worker threads, and at most the run's fanout window, 8 by default, run at once.
Each arm seeds var from a fresh clone of the caller's snapshot, so arm writes never cross arm boundaries or reach the caller. The store is shared, with one guard: two arms of one fanout writing the same store path fail with a write-write race error, while store.append to one path stays legal with unspecified order.
Arms can still rendezvous through the store. They write marker files and poll with store.glob, and each poll iteration yields through call on a no-op section so sibling arms get scheduled.
Failure semantics
A fatal arm error fails the fanout and aborts the sibling arms; the caller can catch it with pcall and continue. A softer case degrades instead: an arm whose tool loop exhausts becomes an incomplete stub result with .ok == false and .exhausted == true, and the sibling arms survive.
Control flow from an arm
A fanout arm can jump. The arm's visible set is the fanout caller's visible set minus the worker, plus the worker's children. A child walk started from an arm runs with no item seed.
Recursion depth accumulates across a fanout boundary: an arm runs one call level deeper than its caller, so a call or fanout near the cap of 8 trips it.
Choosing a worker
Any section in the caller's visible set can serve as a fanout worker, and one worker can be shared by multiple sibling callers. The walk never descends into child sections on its own, so a worker written as a child section (an H3 under its caller's H2) runs only when addressed. A worker still counts in sys.section_count.
Agent Programs
- Agent programs
- The agent loop
- Chat rounds
- Tool calls
- The event log
- Host state
- Files and variables
- The sandbox
- Errors and cancellation
- The full loop
Agent programs
This chapter teaches you what an agent is, the file you write, and how the Workshop runs it. Learn it first, because an agent is an ordinary PromptForge prompt document: everything the prompt language gives a prompt - sections, Lua blocks, model rounds, tools, the store - an agent has too. What makes it an agent is only where the file lives and who is listening.
Write the smallest working agent
---
name: hello
description: The smallest working agent.
promptforge: 0
---
# Hello
## Speak
```lua
log('hello from my agent')
```
Save that file as hello.md in the agents directory. The file is the whole agent: frontmatter that makes it a prompt, one title, one section, one Lua block. There is no manifest, no registration step, and no second file. When the host runs it, the log call records the message hello from my agent in the run's event stream, and the prompt runs to its end.
How the Workshop runs an agent
The Workshop discovers agents by reading the agents directory: every .md file there is a launchable agent, listed under its file-stem name in a sorted list. Discovery reads the directory per request, so a file you add shows up in the agent list on the next connect, with no restart. A missing or unreadable directory is a state, not an error: the list simply offers the built-in chat alone.
Launching an agent parses the file as a PromptForge prompt and runs it on the unified document runtime, the same runtime that runs every other prompt. One launch is one prompt run: sections walk in order, Lua blocks suspend on host calls and resume with their answers, and the run ends when the document ends - or when the operator cancels it.
The directory itself is a configuration value: agents.path in workshop.toml. The default is agents/ beside the config file.
The agent's name
The agent's name is the .md file stem. Save the prompt as hello.md and the agent's name is hello. Discovery yields bare stems only, so a launch request can never be coaxed into naming a path.
The name follows the run everywhere it leaves a trace: the agent list, the session panel, and the persisted event log all key on it.
The built-in chat and the shadow
A fresh install always offers a working chat agent, even when there is no agents directory at all. The built-in chat is a Markdown prompt embedded in the Workshop at compile time, and discovery always lists it.
Save your own prompt as chat.md in the agents directory and it shadows the embedded source: the list still shows one chat, but launching it runs your file. That is how your own agent takes over the chat role. An existing chat.md that cannot be read surfaces its error instead of silently serving the embedded source.
The session surface
An agent prompt runs with two extras an unattached prompt does not have, both installed by the session. user_input() suspends the run until the operator types an answer, and returns the answer text together with an availability flag. ui() returns a fresh snapshot of host state on every call; its selected_model field names the model currently selected in the interface, so an agent that re-reads it each turn follows the operator's menu choice.
Everything else is the prompt language, exactly as the Prompt Language set teaches it: models.infer and models.loop run model rounds, tools.add brings tools into scope, store reads and writes files, var holds per-run state, and log records messages in the event stream.
The moving parts
Two crates carry an agent run. workshop-sessions owns discovery, launch, and the session extras: the input broker behind user_input(), the ui() snapshot, and the persisting event log. promptforge-api is the unified runtime that parses and runs the prompt itself. The final chapter of this set walks through the built-in chat program, the one agent every install already has.
The agent loop
This chapter teaches you the loop shape every agent follows. It is worth learning because the loop is how your program and the host talk: once you understand one request in flight, every host call reads the same way.
The smallest loop
models.use('writer')
while true do
local text = models.infer('Write one sentence about the sea.')
log(text)
end
Run this program and it asks the model for one sentence about the sea, records the answer, and starts again. It keeps going until the host cancels the run.
Work through the lines. models.use('writer') selects the catalog model named writer for the run. while true do starts a loop with no exit of its own. models.infer('Write one sentence about the sea.') runs one direct, tool-free text completion on a fresh conversation and resumes with the completed text. log(text) records that text in the run's event stream.
One request in flight
Each host call suspends your program with exactly one request in flight. When your program calls models.infer, it stops at that line and the host takes over. The host dispatches the one request and resumes your program at the same line with that request's answer as the return value.
Write your program as if each host call were an ordinary synchronous call. There is no callback and no second request to track: the program carries exactly one request in flight, and it always resumes with that request's answer.
The two round calls
Two calls carry almost every agent. models.chat(messages, opts) runs one stateless model round over a message list your program builds, and the round is tool-capable. tools.call(alias, args) dispatches any tool in the agent's catalog by its wire name, and every tool in the catalog is in scope under its alias.
Both calls follow the loop rule: one request in flight, resumed with the answer. Everything else about them is detail on top of that rule.
When the loop ends
A loop that never returns is legal. The host lets it run, and only the run's cancel flag stops it. Your program chooses its own shape: return when the work is done, or loop until the run is cancelled.
Chat rounds
This chapter teaches you how your agent talks to a model. You will build a message list, run one round with models.chat, and read what the round produced. Your agent spends most of its life inside this call, so it pays to learn it exactly.
A direct completion
models.use('writer')
local text = models.infer('Give this workshop a one-word name.')
log(text)
models.infer(prompt) runs one direct, tool-free text completion on a fresh conversation, and the call resumes with the completed text. Every call starts fresh: nothing carries over from one models.infer call to the next.
Select the model first. models.use('writer') selects the catalog model named writer. An agent run has no default model, so a bare models.infer with no selection fails: "no model is selected: call models.use(...) before models.infer".
Select once. Your program selects only one model per run, and the runtime rejects a second selection.
A chat round
local messages = {
{ role = 'system', content = 'You answer in one word.' },
{ role = 'user', content = 'What color is the sky?' },
}
local result = models.chat(messages)
log(result.reply)
models.chat(messages, opts) runs one stateless model round over the message list you build. Stateless means the list you pass is the whole conversation. You build it fresh, or you grow it yourself between rounds.
Build each message as a table with a role and a content. The role is one of system, user, assistant, or tool. The content is a plain string. Two more fields, tool_call_id and tool_calls, exist for rounds that involve tools. Any other fields you add are accepted and then dropped before the request is sent.
Get a role wrong and the error tells you where: the message names the 1-based index of the offending entry in your own list, as in messages[2] role "wizard" is unknown.
The result table
local result = models.chat(messages)
if result.reply then
log(result.reply)
end
A models.chat round returns a result table with five fields: reply, tool_calls, finish_reason, model, and metrics.
Read the outcome from reply and tool_calls. Exactly one of them is present: the round produced text, or it requested tool calls. Never both. When the round produced text, result.reply holds the completed text.
result.model names the model that served the round. result.metrics carries usage and backend timing. The metrics field is absent when nothing was measured, and absent optional fields read back as nil.
Check finish_reason for one thing: a value of "length" means the text reply was truncated.
Do not branch on finish_reason to detect tool calls. Some backends finish a tool-call round with "stop", and the calls still surface. Branch on the presence of result.tool_calls instead.
Choose the model for one round
local result = models.chat(messages, { model = 'writer' })
opts.model chooses the chat model for one round. Pass a catalog model name. Without opts.model, the round uses your program's models.use selection. With neither, the call fails: "no model is selected: pass opts.model or call models.use(...) before models.chat".
Every model in the catalog is addressable by its catalog name, through models.use and through models.get. There is no default model in an agent run.
A bound handle
local handle = models.get('writer')
log(handle.name)
log(handle.model_id)
local text = models.infer(handle, 'Write a haiku about rain.')
models.get addresses a catalog model by name and gives you a bound handle. models.infer(handle, prompt) runs the same kind of round as models.infer(prompt): one direct, tool-free completion on a fresh conversation, using the handle's frozen binding. Handles are plain inspectable values with no methods; every operation that accepts one takes it as the leading argument.
The handle's fields are read-only. name is the prompt-local alias. model_id is the caller-facing catalog model id. description is the capability description given at bind time. context is the catalog context window size in tokens. thinking, temperature, and max_tokens expose the frozen invocation settings, and they read nil when the bind declared none.
Send an image
local messages = {
{ role = 'user', content = {
{ type = 'text', text = 'What does this sign say?' },
{ type = 'image_url', image_url = { url = 'data:image/png;base64,AA' } },
} },
}
local result = models.chat(messages, { model = 'writer' })
Pass content as a non-empty array of content parts when a message mixes text and images. A content part has a type of text or image_url. An image_url part carries a data-URI, which sends the image to a multimodal model.
Agent-only
models.chat exists only in an agent. The same call inside a document prompt fails as an undefined global.
Tool calls
This chapter teaches you how your agent invokes tools. You will advertise tools to the model, read the calls the model asks for, dispatch them yourself, and answer them in the next round. Tools are how your agent reaches past the model, so the rules around them matter as much as the calls.
Advertise tools to the model
local result = models.chat(messages, { tools = { 'echo' } })
List the tool aliases the model may ask for in opts.tools. The default is no tools. Pass no tools option and the round advertises none; the driver never adds any on its own.
Read the requested calls
local result = models.chat(messages, { tools = { 'echo' } })
if result.tool_calls then
for i = 1, #result.tool_calls do
local call = result.tool_calls[i]
log(call.name)
end
end
A round with advertised tools can come back with requested tool calls. The requested calls arrive unexecuted. Running them is your program's decision, never the driver's.
Read each requested call from the 1-based entries of result.tool_calls. Each entry carries three fields: id, name, and arguments. The arguments field is already a Lua table, so you can pass it straight on.
Dispatch a call
local output = tools.call('echo')
tools.call(alias, args) dispatches any tool in your agent's catalog by its wire name. Every tool in the catalog is in scope under its alias. Omit args, or pass nil, to call a tool without arguments; the tool receives the empty argument object.
The call resumes with the tool's result. A tool that declares structured output returns its result as a Lua table. Every other tool returns plain text. A structured tool that returns invalid JSON fails the call, and the error names the alias.
Answer a tool call
local result = models.chat(messages, { tools = { 'echo' } })
if result.tool_calls then
local call = result.tool_calls[1]
local output = tools.call(call.name, call.arguments)
messages[#messages + 1] = { role = 'assistant', content = '', tool_calls = { { id = call.id } } }
messages[#messages + 1] = { role = 'tool', tool_call_id = call.id, content = output }
result = models.chat(messages, { tools = { 'echo' } })
end
The model asked for the call, so the next round must report what happened. Append two messages to your list. First replay the assistant's tool-calling round with an assistant message that carries the round's tool_calls array. Then answer the call with a tool role message that carries the string tool_call_id of the call it answers, with the tool's output as its content.
Count the dispatches
tools.call('echo')
tools.call('echo')
if tools.calls['echo'] == 2 then
log('echo ran twice')
end
The read-only tools.calls table shows how many times each alias has been dispatched in the run. The count increments on every attempted dispatch, even when the tool goes on to fail.
Trust and the envelope
local wrapped = untrusted('a < b')
Tool output reaches your program under a trust flag. Trusted output passes to the next model turn or to your script verbatim. Untrusted output arrives inside an <untrusted_input_...> envelope, wrapped by the host before it can reach the next model turn or your script.
Mark your own strings with untrusted(s). The call wraps the string in an envelope tagged with the run's guard nonce. The envelope has a fixed preface. Every literal < in the string is escaped as <, so exactly one live open tag and one live close tag remain. Every untrusted() call in a run shares one nonce, so identical content produces a byte-identical envelope.
No MCP tools yet
There are no MCP server tools yet. The mcp request shape is reserved.
The event log
This chapter teaches you how your agent reads what has already happened in the run. The host keeps an event log, and runtime.events() gives your program a window into it. Your context building reads this log, so learn the read rules exactly.
Read the log
local events = runtime.events()
for i = 1, #events do
local event = events[i]
log(event.kind)
end
runtime.events() returns a read-only indexed view over the host's event log. #events gives the number of visible events. Read one event at a time by position: events[1] is the first visible event. Each positional read brings only that single entry into Lua, so indexing a long history never bulk-copies the log.
Reads stay deterministic
The view grows only at host-call resumes, never mid-chunk. Between two host calls, #events does not change and no entry appears or moves. Reads you make between suspensions stay deterministic, so you can loop over the view without guarding against growth.
Index safely
local second = events[2.0]
local a = events[0]
local b = events[-1]
local c = events.latest
Indexing follows ordinary Lua rules and never fails. A float key with an exact integer works like ordinary indexing: events[2.0] reads entry 2. An out-of-range index reads nil. Zero, negative, and non-numeric keys read nil, so events[0], events[-1], and events.latest never error. Even an in-bound entry the log no longer holds reads nil. Reads never fail your program.
History is read-only
The view is read-only. Assigning into it, as in events[1] = 'x', raises an error. Your program cannot rewrite history.
A fetched entry is a fresh table. Mutate it freely: add fields, reorder them, hand the table to a function that changes it. The mutation cannot reach the log.
What an entry carries
Each entry carries fields such as kind and content. The kind reads as a pinned label, such as "agent_message", and content holds the entry's text. Entries also carry metadata you use to reconstruct context: section, chain_id, depth, turn, model, tool_call_id, finish_reason, and metrics.
Tool activity leaves a clear trail. Every dispatched tool call emits a tool-call-succeeded or tool-call-failed event. Each tools.call also emits a tool-result event that carries the chain id, the execute depth, the completed model-turn count, the tool alias, the final content, and the trust flag.
History across runs
A relaunched agent sees its whole persisted history from its first instruction. The view starts with everything the log already holds.
Run the same code with no log configured and runtime.events() returns a plain empty table of length 0. The read loop still works; it just iterates zero times.
The runtime global exists only in an agent. Its presence proves the agent environment.
Host state
This chapter teaches you what state the host exposes to your agent and how to read it. Two globals carry it: ui, a live snapshot of host state, and sys, a sealed table. Knowing the difference keeps you from trusting a stale read or poking a table that pushes back.
Read the UI snapshot
local snapshot = ui()
log(snapshot.selected_model)
ui() returns a fresh host-state snapshot. Every call re-queries the host. There is no caching, so two calls in a row can legitimately give you two different answers.
Read a field the host has not set and you get nil: a JSON null field in the snapshot reads as nil, never as a placeholder.
The ui global exists only when the host supplies a provider. When the host supplies none, the global is absent entirely. Check for its presence before you rely on it.
The sealed sys table
The sys global is installed sealed and empty in an agent run. Any field read raises an error that names the field. Access fields by string key only; any other key type raises. The table is read-only, and its seal cannot be replaced from your code. A present-but-null field reads as nil rather than as a placeholder, but an agent run has no such fields, so every read still raises. In an agent run there is nothing behind the seal, so treat sys as off limits.
Files and variables
This chapter teaches you how your agent works with files and per-run state. The store table reaches files, the var global holds run state, and log writes to the event stream. These are the calls that let your agent leave something behind, so their limits are worth learning.
Write and change files
store.write('notes.txt', 'first line\n')
store.append('notes.txt', 'second line\n')
store.str_replace('notes.txt', 'first', 'opening')
store.delete('draft.txt')
The store table is always installed. Tool scoping never removes it. Data you write through store persists past the run, and the host sees it after the run.
store.write(path, contents) writes a file and returns nil. store.append(path, contents) appends text to a file and returns nil. store.str_replace(path, old, new) replaces text in a file and returns nil. store.delete(path) deletes a file and returns nil.
Read files
local whole = store.read('notes.txt')
local tail = store.read('notes.txt', 10)
local slice = store.read('notes.txt', 10, 20)
local numbered = store.read_numbered('notes.txt', 10, 20)
store.read(path) returns the file's contents verbatim. Add a 1-based start line to read from that line to the end of the file. Add an end line to read an inclusive range. Passing end without start is an error: "start is required when end is given". store.read_numbered(path, start, end) returns the file with absolute line numbers and takes the same optional bounds.
Find files
local paths = store.glob('*.txt')
if store.exists('notes.txt') then
log('notes exist')
end
store.glob(pattern) returns an array table of matching paths. store.exists(path) reports whether a path exists.
When a store call fails
A failed store operation aborts the running chunk with an error. Every store operation, success or failure, records an event in the run's event stream, so the log shows what your agent touched.
Hold run state in var
var.count = 1
var.seen = { 'alpha', 'beta' }
var.note = 'ready'
log(var.note)
if var.missing == nil then
log('nothing stored yet')
end
The var global stores per-run state. Absent keys read as nil, which is why the var.missing check passes. Your program also reads host-supplied run variables through var.
Assign only JSON-representable values into var. Functions, userdata, and threads are rejected at the assigning line. Nested tables you assign come back guarded, so later writes into them cross the same validation. When a write fails, the error names the dotted path of the offending field, such as var.a.b. Reassigning the var global itself is detected and reported; write var.<field> instead.
Record messages with log
log(message) records a message in the run's event stream, where later context building can read it. The call takes exactly one argument, and it must be a UTF-8 string. Keep the message to at most 256 characters, with no newline or control characters.
Log calls are capped by a per-run event budget and a cumulative byte budget. The event budget is configured per run with lua_log_events, and the default is 1024 events. An exhausted budget fails the call.
Ask the operator
tools.call('user_input', {})
Request the operator's next message by invoking the user_input tool through tools.call. The operator's answer arrives in the event log as a user_message event, where your context building can read it.
The sandbox
This chapter teaches you the limits the host enforces on your agent and what you can rely on inside them. Your code runs in a sandboxed Lua runtime, isolated from the host. The sandbox is what lets the host run author code safely, so it shapes every program you write.
A fresh VM for every program
Each program runs in a fresh, restricted VM. Your file is compiled and run as Lua 5.5. Scripts execute isolated from the host: the only way in or out is a host call.
What you have
Only the string, table, and math standard libraries are loaded, plus safe base functions. That is the whole standard library surface.
What is removed
The code-loading and reflection globals are removed: load, loadstring, dofile, loadfile, collectgarbage, require, getfenv, setfenv, rawget, rawset, rawequal, rawlen, print, and warn. A script cannot load code dynamically, bypass the module system, or print directly.
The io, os, package, coroutine, and debug libraries are never loaded. Scripts have no file, OS, module-loading, or introspection access. File work goes through store, not io.
Time and memory
Long-running and infinite loops are legal. The instruction budget is effectively unlimited, so a script is never killed for executing too many instructions. Only the run's cancel flag aborts a loop.
The VM runs under a Lua heap ceiling configured per run with lua_memory_bytes. The default is 64 MiB.
When your code trips a host quota, the error names the exhausted resource: "log event", "log byte", or "instruction".
No direct yields
Your program cannot yield on its own. The coroutine global is stripped from author reach, and a hand-rolled yield fails the run: "scripts may not yield directly". Treat suspending host calls as ordinary synchronous calls, and let the host do the suspending.
Errors and cancellation
This chapter teaches you what happens when a call fails or the run is cancelled, and how your agent responds. Most failures in this system are answers, not ambushes: they come back where the call was made, and your program chooses what to do next.
Catch call failures with pcall
local ok, result = pcall(models.chat, messages, { model = 'writer' })
if not ok then
log(result)
end
Wrap host calls in pcall to catch argument-validation and dispatch failures. These failures come back as the call's answer. They do not fail the run. A failed host call raises a Lua error that carries exactly the host's message, so the value your pcall catches is the message the host sent.
Errors that name things
The error messages are built to be read. A tools.call or an opts.tools entry that names an unregistered alias fails, and the error names the in-scope aliases. An opts.model outside the agent's catalog fails, and the error names the model. A bad chat message fails with the 1-based index of the offending entry in your own list, as in messages[2] role "wizard" is unknown.
A models.chat tool-call round fails when the model truncates it. Your program never resumes with a partial batch of tool calls. You get the failure instead.
Runtime errors in your code
When your Lua code itself fails, the error names the location and the absolute line, prefixed as {location}:{line}:. In an agent run the location renders as agent `<name>` , with your agent's file stem as the name, so the message points back at your file.
Cancellation
A host-fired cancel interrupts the run, even while a host call is suspended or Lua code is running. The run ends with the typed error "interrupted".
Every tool call is raced against the cancel signal. On cancel, the tool future is dropped, so a slow or stuck tool cannot hold the run. Your program does not see a partial tool result. The run simply ends.
The full loop
This chapter assembles the complete agent: the built-in chat itself, one Markdown prompt embedded in the Workshop. A prompt saved as chat.md in the agents directory shadows it, so the chat you already use is a role your own agent can take. Walk through that program turn by turn, because the whole session surface shows up in it, working together.
A chat agent
A chat agent is a transparent pass-through. It advertises no tools and sets no system prompt. It relays between the operator and the selected model, and nothing else. That restraint is the design: the program adds no behavior the operator did not ask for.
One turn
The agent is one infinite loop in a single Lua block. Each turn does the same four things, in order.
- Call
user_input()to request the operator's next message, and return from the program when input is no longer available. - Append the operator's message to the retained history list.
- Read the operator's selected model from the
ui()snapshot'sselected_modelfield. - Run
models.loopover the history under that model, wrapped inpcall, then loop back to step 1.
The full program
---
name: chat
description: The built-in Workshop chat agent on the unified runtime.
promptforge: 0
---
# Chat
The built-in chat agent: a transparent pass-through between the operator
and the selected model. The message list is an explicit Lua value retained
across turns; the model is re-read from the host snapshot every turn, so a
menu selection change takes effect on the next turn.
## Conversation
```lua
local history = messages.new()
while true do
local text, available = user_input()
if not available then
return
end
history:user(text)
local selected = ui().selected_model
if selected then
pcall(function() return models.loop(models.get(selected), history) end)
end
end
```
This is the whole chat surface. Work through the lines. messages.new() builds the empty conversation list once, before the loop starts. user_input() suspends the run until the operator answers, and returns the answer text together with an availability flag; when the flag reads false, the program returns instead of spinning on a dead session. history:user(text) appends the operator's message to the list. ui().selected_model reads the interface's current model selection, and models.get(selected) resolves that selection to a bound handle. models.loop(handle, history) runs the model round over the list, and the reply is appended to that same list as the final record, so the list the program passed in comes back one turn longer.
Why the list is the state
Notice what the program never does: rebuild the conversation. The list is created once and retained across turns, and both sides accumulate in it - the program appends each operator message with history:user(text), and models.loop appends each assistant reply as it completes. The next turn's model round therefore sees the whole conversation, and the program never copies, re-derives, or re-reads anything.
Because the model is re-read from the ui() snapshot on every turn, never captured once before the loop, a menu selection change takes effect on the very next turn.
Why pcall wraps the model call
The loop runs models.loop under pcall because chat survives transport errors, and so must this program. A failed round does not kill the agent. The session surfaces the failure to the operator, and the loop returns to user_input() for the next turn.
Grow from here
Start from this program and add one capability at a time. Bring a tool into scope with tools.add before the loop call and the model can ask for it. Save notes with store.write. Keep a counter in var. The loop does not change. The turns just do more.