Skip to main content
Local functions are .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 experimental. The endpoints, the function file contract, the context API, custom _auth, and the [functions] config keys may change between releases without a deprecation window. The server prints this notice at startup. Pin your bricks-buttress version if you build on it, and re-read this page after upgrading.
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 writes buttress-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 namevideo-duration.ts becomes the video-duration tool.
Rules:
  • The default export must be a function. It receives (input, context).
  • meta is optional. meta.parameters is 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.tempDir and return context.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 _.
Discovery skips files beginning with _, 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:
Pass that 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, set hot_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

Errors come back as { "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:
Query values are always strings, so the function’s declared 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 into context.tempDir, return context.fileUrl(path), and the caller fetches <base><url> with the Authorization header it already holds:
Downloads sit behind the same auth guard as calls, only ever resolve inside the functions scratch root — traversal, directories, and undecodable paths are all indistinguishable from a missing file — and stay available until the scratch sweep, roughly 24 hours later. Binary inputs go the other way, in a single request. Post 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:
The function receives 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:
Client file names are sanitized to bare names in both paths, staged files share the auth guard and the 24-hour sweep with function outputs, and request size is capped by [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:
Point it at a specific host with --url, and drop the token for a server running unauthenticated:
The written entry embeds a long-lived workspace access token. Treat .mcp.json as a secret and keep it out of version control.
Discovery never mints a token for a host that does not report your workspace: an unbound or foreign server is a mismatch, and the command refuses rather than handing a credential to whichever machine answered the probe. Target it with --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_BLOCKED unless its origin is listed in [functions] cors_allowed_origins. Agents, MCP clients, and curl are unaffected.
Downloads and uploads sit behind the same guard as calls. The browser check reads two headers, because 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”.
A listed origin only helps requests that carry one. A no-CORS load has no origin to match, so only cors_allowed_origins = "*" lets those through.
To run functions on a server with no workspace binding, opt in explicitly:
allow_unauthenticated = true lets anyone who can reach the port run every function. Only do this on a trusted network.
Function files are trusted input, exactly like the config file itself. They run with a clean global — no ambient 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, and allow_unauthenticated stops mattering. A presented workspace token is still verified into request.workspaceAuth, so you can keep honoring workspace tokens while also accepting other credentials.
The handler receives { 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 in config/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.