.ts / .js files you drop into a directory on a BRICKS Buttress server. Each file becomes an MCP tool and an HTTP endpoint, so an agent — or any HTTP client — can run work on the server: shelling out to ffmpeg, calling the server’s own LLM, speech-to-text, and text-to-speech generators, or reaching an internal service. You write one file; you do not write a service.
Local functions are different from the rest of Buttress. Everywhere else, Buttress offloads a generator that a BRICKS Foundation device would otherwise run itself. Local functions run on the server, are authored by whoever operates it, and are consumed by agents and HTTP clients — not by the app runtime.
Enable
Functions are off by default. Turn them on with a[functions] table in the server TOML:
The first three keys have environment-variable equivalents:
ENABLE_FUNCTIONS_ENDPOINT=1, BUTTRESS_FUNCTIONS_DIR=<dir>, and BUTTRESS_FUNCTIONS_HOT_RELOAD=1. BUTTRESS_FUNCTIONS_ALLOW_UNAUTHENTICATED=1 matches allow_unauthenticated.
If
enabled = true but no directory is configured, the server logs a warning and leaves functions off rather than guessing a path.What the server scaffolds
On every start the server creates the directory if it is missing and writesbuttress-functions.d.ts — the ambient types for ButtressFunctionMeta, ButtressFunctionContext, and the ButtressAuth* types. That file is refreshed each start, so it always matches the running server. Treat it as the source of truth when this page and your server disagree.
When the directory holds no functions yet, the server also seeds a tsconfig.json and a commented _example.ts. Neither is ever overwritten once you have edited it.
Write a function
One file is one function, and the file name is the tool name —video-duration.ts becomes the video-duration tool.
- The default export must be a function. It receives
(input, context). metais optional.meta.parametersis plain JSON Schema and is handed to MCP clients verbatim, so you describe the input once and every surface agrees. Without it the tool advertises an empty object schema.- Return a JSON-serializable value. For files, write into
context.tempDirand returncontext.fileUrl(path)— see Send files in and out. - A function name must start with a letter or digit and may otherwise contain letters, digits,
-, and_.
_, dotfiles, *.d.ts, *.test.*, *.spec.*, and subdirectories — subdirectories are for helper modules you import. _auth.ts is the one exception to the underscore rule: it is live, and it changes how every endpoint authenticates. See Custom auth. If a .ts and a .js file share a name, the .ts file wins.
mcp, files, and upload are reserved names, because /functions/mcp, /functions/files/*, and /functions/upload are routes. Files with those names are skipped.
Context
The second argument carries everything a function can reach:Imports
A function may import Node builtins, sibling files inside the functions directory, and two allowlisted packages:sqlite3 and sqlite-vec. Every other package specifier is rejected — use context.libs for the bundled helpers.
sqlite-vec ships extensions for macOS and Linux on x64 and arm64, plus Windows x64. It publishes none for Windows arm64, so the standalone build for that platform offers neither SQLite import; every other part of this page still works there.Retrieval
tokenize, embedding, and the two SQLite imports are enough to do retrieval inside one function: chunk on token boundaries, embed each chunk, keep the vectors in a sqlite-vec virtual table, and hand the nearest matches to completion.
Embeddings need a model loaded in embedding mode, which a chat model is not. Give the embedding model its own [[generators]] block and leave the chat model as the first LLM:
repo_id as model on every embedding, tokenize, and detokenize call, and omit model on completion so the answer comes from the first LLM. See configuration for the full key list.
The index itself is plain SQLite: open a sqlite3 database, load the sqlite-vec extension into it with getLoadablePath(), and write each vector as a float32 BLOB. :memory: gives an ephemeral index; a file path — absolute, or relative to the functions directory — keeps one on disk between calls.
Iterate
Edits land on the next call. Before each call the server checks every file in that function’s module graph and reloads when one changed, so you never restart the server. A file that fails to load is logged and skipped while every other function keeps working — if a function stops appearing, check the server log. For faster feedback, sethot_reload = true. The server then also watches the directory and reloads on save, so a broken file is logged the moment you save it and the function count moves without waiting for a call. This is purely additive: the per-call check stays on as the backstop, and on platforms without recursive directory watching the server logs a warning and falls back to it.
Module-level state — a let outside the handler — persists between calls and resets when you edit the file.
Call a function
{ "error": { "code", "message" } }:
Auth denials carry their own codes — see Security.
Call one with GET
GET /functions/<name> runs the same call with no body at all — the query string is the input. That is what a browser address bar, a webhook, an EventSource, or a bare curl can produce without ceremony:
meta.parameters doubles as the coercion table. Declared number / integer, boolean, array, and object properties are converted; anything undeclared stays a string:
Coercion never rejects. A value that does not fit its declared type passes through verbatim rather than becoming
NaN, so the handler still sees what the caller actually sent and still owns validation.
For exact types regardless of the schema, pass the whole object as JSON in input. Plain parameters overlay it, exactly like a multipart call:
stream, token, and access_token steer the request and never reach a handler — a function that genuinely wants an input named token takes it through input.
Streaming works the same way: add ?stream=1, or send Accept: text/event-stream, which is all an EventSource can say. Because an EventSource cannot set headers either, pass the token as ?token=… on a bound server.
GET is offered for every function, and responses are sent
no-store. HTTP expects a GET to be safe to repeat, and only you know whether yours is — pick the method that matches what your function actually does.Send files in and out
Binary results travel by URL, not by value. Write intocontext.tempDir, return context.fileUrl(path), and the caller fetches <base><url> with the Authorization header it already holds:
multipart/form-data to the function and every file field is staged into that call’s scratch directory, with its server-local path injected into the input under the field name:
input.file as a path it can hand straight to ffmpeg, so the whole flow works remotely with no filesystem access. Add -F input='{"model":"…"}' for typed values; other plain fields arrive as strings, file paths win name collisions, and a repeated file field becomes an array of paths. ?stream=1 works with multipart too.
To stage one file and reuse it across calls, upload it separately and pass the returned path in a JSON call:
[server] max_body_size (50 MB by default) — raise it for large media.
Connect an agent
bricks buttress mcp-config from BRICKS CLI writes the .mcp.json entry for a server’s MCP endpoint. It discovers the server on the LAN, mints a workspace access token, and merges the entry into the file without touching your other MCP servers:
--url, and drop the token for a server running unauthenticated:
--url if that is what you meant. See bricks buttress mcp-config for every option.
Security
Functions run code and spawn processes on the host, so this surface is fail-closed — unlike the inference endpoints, it does not inherit “unbound means open”.- Unbound server — every call is rejected with
403 FUNCTIONS_UNAUTHENTICATED_DISABLED. - Bound server — a workspace access token is required, exactly like every other data path. See Workspace binding.
- Browser requests — anything the browser marks as cross-site is rejected with
403 FUNCTIONS_ORIGIN_BLOCKEDunless its origin is listed in[functions] cors_allowed_origins. Agents, MCP clients, andcurlare unaffected.
Origin alone does not cover a GET call. A CORS request carries Origin and is matched against the allow-list. A no-CORS subresource load — <img src>, <script src>, a prefetch — sends no Origin at all, so it would otherwise read as a non-browser caller; browsers mark those with Sec-Fetch-Site, a forbidden header no script can set, and cross-site or same-site names exactly the loads Origin leaves out. Its absence still means “not a browser”.
To run functions on a server with no workspace binding, opt in explicitly:
process or require — but that is for clarity, not containment: a function handed spawn can do anything the server process can. Never put code you have not written or reviewed in the functions directory.
Every call carries a deadline from meta.timeout or [functions] default_timeout. When it expires, or when the caller disconnects, context.signal fires and every process the call spawned is killed. Code that blocks the event loop synchronously cannot be interrupted, so keep handlers async.
Custom auth
An_auth.ts (or .js) in the functions directory puts your own logic in front of every /functions endpoint. It has the same shape as a function file, and meta.mode picks how it composes with the workspace gate above:
both(default) — workspace auth runs first, unchanged, and your handler runs after it as an extra gate. It can only narrow access: per-function allow-lists, subject checks, audit logging.override— your handler alone decides, andallow_unauthenticatedstops mattering. A presented workspace token is still verified intorequest.workspaceAuth, so you can keep honoring workspace tokens while also accepting other credentials.
{ method, path, name?, headers, query, token, workspaceAuth } — name is set only for GET / POST /functions/<name>, because MCP tool names live in a JSON-RPC body the guard never parses — and a trimmed context of log, fetch, env, config, dir, and libs. There is no spawn and no buttress.
Only true or { ok: true } allows a request. Anything else denies with 403 FUNCTIONS_AUTH_REJECTED, or with the status and error you return. Auth never fails open: while an _auth file exists but does not load, every call is rejected with 500 FUNCTIONS_AUTH_UNAVAILABLE, and a handler that throws denies with 500 FUNCTIONS_AUTH_ERROR. The browser cross-site block always runs first, so a permissive _auth cannot re-open it.
_auth.ts reloads lazily like a function file, and deleting it restores plain workspace auth.
bricks buttress mcp-config mints workspace tokens. With an override-mode _auth that ignores workspaceAuth, those tokens stop working — keep the workspace-token branch shown above if agents still connect that way.Watch what happened
You do not need server-log access to see how a function behaved.GET /buttress/status carries a functions section with counters since startup and recent history for calls (tagged per surface — HTTP, SSE, or MCP — with durations and failure reasons), uploads, downloads, and auth decisions. The same data renders as a Local Functions card on the server’s /status dashboard.
Only metadata is recorded — names, paths, sizes, outcome codes, subject ids. Tokens and keys never are.
Discovery advertises presence, not contents: serverInfo.functions is { enabled, count }. The tool list itself is never announced, because the whole serverInfo has to fit in a UDP datagram. See LAN auto-discovery.
Samples
The server package ships ready-to-copy examples inconfig/function-samples/. Copy one into your functions directory and call it — no restart needed.
_auth.ts is not a function — copying it changes how every /functions endpoint authenticates callers.
Next steps
Configuration
The full TOML reference, including the generators functions call.
Workspace binding
Bind the server so function calls require a workspace token.