> ## Documentation Index
> Fetch the complete documentation index at: https://docs.bricks.tools/llms.txt
> Use this file to discover all available pages before exploring further.

# Local functions

> Expose server-side .ts/.js files on a BRICKS Buttress server as MCP tools and HTTP endpoints

Local functions are `.ts` / `.js` files you drop into a directory on a [BRICKS Buttress](/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.

<Warning>
  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.
</Warning>

Local functions are different from the rest of Buttress. Everywhere else, Buttress offloads a generator that a [BRICKS Foundation](/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:

```toml theme={null}
[functions]
enabled = true
dir = "./functions"              # relative paths resolve against this config file
# default_timeout = "5m"         # per-call deadline; a function may override it
# hot_reload = false             # watch the directory and reload on save
# allow_unauthenticated = false  # see Security below
# cors_allowed_origins = ["http://localhost:3000"]

[functions.config]               # optional; reaches functions as context.config
# api_base = "https://example.internal"
```

| Key                     | Type               | Default | Description                                                                                                           |
| ----------------------- | ------------------ | ------- | --------------------------------------------------------------------------------------------------------------------- |
| `enabled`               | boolean            | `false` | Enable the `/functions` surface                                                                                       |
| `dir`                   | string             | —       | Functions directory. Required; relative paths resolve against the config file, and `~` expands to your home directory |
| `default_timeout`       | number or string   | `"5m"`  | Per-call deadline in ms, or a duration string. A function's `meta.timeout` wins                                       |
| `hot_reload`            | boolean            | `false` | Watch the directory and reload on save, in addition to the per-call check                                             |
| `allow_unauthenticated` | boolean            | `false` | Let an **unbound** server accept calls. See [Security](#security)                                                     |
| `cors_allowed_origins`  | string\[] or `"*"` | —       | Browser origins allowed to call functions. Empty means no browser may; only `"*"` admits no-CORS loads                |
| `[functions.config]`    | table              | `{}`    | Free-form values handed to every function as `context.config`                                                         |

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`.

<Note>
  If `enabled = true` but no directory is configured, the server logs a warning and leaves functions off rather than guessing a path.
</Note>

### 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 name** — `video-duration.ts` becomes the `video-duration` tool.

```ts theme={null}
export const meta: ButtressFunctionMeta = {
  description: 'Transcribe the audio track of a video file',
  parameters: {
    type: 'object',
    properties: {
      path: { type: 'string', description: 'Path to a video on the server' },
    },
    required: ['path'],
  },
  timeout: '10m',
}

export default async function ({ path }: { path: string }, context: ButtressFunctionContext) {
  const wav = `${context.tempDir}/audio.wav`
  const { code, stderr } = await context.spawn('ffmpeg', ['-i', path, '-ar', '16000', wav])
  if (code !== 0) throw new Error(`ffmpeg failed: ${stderr}`)

  context.emit('progress', { stage: 'transcribing' })
  return context.buttress.transcribe({ filePath: wav })
}
```

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](#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](#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:

| Member                                                                | What it does                                                                                                                                                                                                                                                                                                                                  |
| --------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `spawn(cmd, args?, opts?)`                                            | Run a child process; resolves `{ code, signal, stdout, stderr, truncated }`. **A non-zero exit resolves** — check `code` yourself. It rejects only when the process cannot start. Options: `cwd`, `env`, `input`, `encoding` (`"utf8"` or `"buffer"`), `maxBuffer` (8 MB per stream by default), `onStdout`, `onStderr`                       |
| `buttress.completion({ model?, messages, max_tokens?, onToken?, … })` | Chat completion on this server's LLM generator → `{ content, reasoning_content?, tool_calls?, usage }`. `model` must name a configured `[[generators]]` entry; omit it for the first one. `messages` go through the model's chat template, and thinking is off unless you pass `enable_thinking: true`. Other keys reach the backend verbatim |
| `buttress.embedding({ model?, text, embd_normalize? })`               | Embed text → `{ embedding: number[] }`. GGML only: `model` must name a `ggml-llm` generator whose `[generators.model]` sets `embedding = true`, and a call against any other generator is rejected. `embd_normalize` picks the llama.cpp normalization mode (`2`, L2, is the native default)                                                  |
| `buttress.tokenize({ model?, text, params? })`                        | Tokenize with a configured GGML or MLX LLM → `{ tokens: number[], … }`                                                                                                                                                                                                                                                                        |
| `buttress.detokenize({ model?, tokens })`                             | Turn token ids from the same model back into text                                                                                                                                                                                                                                                                                             |
| `buttress.transcribe({ model?, filePath \| audioData, options? })`    | Speech-to-text on this server's STT generator. Unlike the LLM path there is no substitution — `model` must match a configured STT model (`repo_id` or `repo_id:filename`)                                                                                                                                                                     |
| `buttress.synthesize({ model?, text, options? })`                     | Text-to-speech on this server's `onnx-tts` or `ggml-tts` generator → `{ path, sampling_rate, channels }`. The WAV is copied into `tempDir`, so pair it with `fileUrl`. `options.speaker` picks a registered voice                                                                                                                             |
| `emit(event, data)`                                                   | Progress event. Delivered to SSE callers; ignored for plain HTTP and MCP                                                                                                                                                                                                                                                                      |
| `signal`                                                              | `AbortSignal`, aborted on timeout or caller disconnect. Pass it to `fetch`                                                                                                                                                                                                                                                                    |
| `tempDir`                                                             | Per-call scratch directory, created on first access                                                                                                                                                                                                                                                                                           |
| `fileUrl(path)`                                                       | Download URL for a file inside `tempDir`. Throws for paths outside this call's scratch directory                                                                                                                                                                                                                                              |
| `log(…)`                                                              | Server log, prefixed with the function name                                                                                                                                                                                                                                                                                                   |
| `fetch`, `env`, `config`, `dir`                                       | Host `fetch`, `process.env`, the `[functions.config]` table, and the functions directory path                                                                                                                                                                                                                                                 |
| `libs`                                                                | Bundled helpers: `_` / `lodash`, `moment`, `math` / `mathjs`, `voca`, `chroma`, `json5`, `qs`, `bytes`, `ms`, `nanoid`, `md5`                                                                                                                                                                                                                 |

### 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.

```ts theme={null}
import fs from 'node:fs/promises'     // builtin — supported
import { helper } from './lib/util'   // sibling file — supported
import sqlite3 from 'sqlite3'         // allowlisted package — supported
import axios from 'axios'             // any other package — rejected
```

<Note>
  `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.
</Note>

### 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:

```toml theme={null}
[[generators]]
type = "ggml-llm"

[generators.model]
repo_id = "nomic-ai/nomic-embed-text-v1.5-GGUF"
filename = "nomic-embed-text-v1.5.Q8_0.gguf"
embedding = true
pooling_type = "mean"
n_ctx = 2048
```

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](/buttress/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

| Endpoint                          | Purpose                                                                                              |
| --------------------------------- | ---------------------------------------------------------------------------------------------------- |
| `GET /functions`                  | List callable functions with their JSON Schemas                                                      |
| `POST /functions/<name>`          | Run one. JSON body is the input object; the response is `{ "result": … }`                            |
| `GET /functions/<name>?…`         | Run one with the query string as its input — no body. See [Call one with GET](#call-one-with-get)    |
| `POST /functions/<name>?stream=1` | Same call as SSE: `progress` events from `context.emit`, then `result` or `error`. `GET` streams too |
| `POST /functions/mcp`             | MCP over Streamable HTTP (stateless; `GET` and `DELETE` return 405)                                  |
| `GET /functions/files/<path>`     | Download a file a function wrote to its scratch directory                                            |
| `POST /functions/upload`          | Stage an input file on the server → `{ "path", "url", "name", "size" }`                              |

```bash theme={null}
curl -X POST http://localhost:2080/functions/host-info \
  -H 'Authorization: Bearer <workspace-access-token>' \
  -H 'Content-Type: application/json' \
  -d '{}'
```

Errors come back as `{ "error": { "code", "message" } }`:

| Code                      | Status | Meaning                                                                                |
| ------------------------- | ------ | -------------------------------------------------------------------------------------- |
| `FUNCTION_NOT_FOUND`      | 404    | No function by that name                                                               |
| `INVALID_INPUT`           | 400    | The request body, or the `input` query parameter, could not be read as an input object |
| `FUNCTION_TIMEOUT`        | 504    | The call passed its deadline                                                           |
| `FUNCTION_FAILED`         | 500    | The handler threw                                                                      |
| `FUNCTION_FILE_NOT_FOUND` | 404    | The download target is missing, or outside the scratch root                            |
| `UPLOAD_FAILED`           | 500    | The upload could not be staged                                                         |

Auth denials carry their own codes — see [Security](#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:

```bash theme={null}
curl 'http://localhost:2080/functions/weather?city=Taipei&days=3&units=metric'
```

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:

| Declared type        | Query form                                                                                 |
| -------------------- | ------------------------------------------------------------------------------------------ |
| `number` / `integer` | `?n=3`                                                                                     |
| `boolean`            | `?verbose=true`, `?verbose=1`, or a bare `?verbose`; use `false` or `0` for the other side |
| `array`              | `?tag=a&tag=b`, `?tag=a,b`, or `?tag=["a","b"]` — items coerce by the schema's `items`     |
| `object`             | `?filter={"lang":"en"}`                                                                    |

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:

```bash theme={null}
curl 'http://localhost:2080/functions/weather?input={"days":3}&city=Taipei'
```

`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.

<Note>
  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.
</Note>

### 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:

```ts theme={null}
const { path } = await context.buttress.synthesize({ text: 'Hello from Buttress' })
return { audio: context.fileUrl(path) }
```

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:

```bash theme={null}
curl -X POST http://localhost:2080/functions/transcribe-media -F file=@interview.mp4
```

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:

```bash theme={null}
curl -F file=@interview.mp4 http://localhost:2080/functions/upload
# → { "path": "…", "url": "…", "name": "interview.mp4", "size": 20972112 }
```

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](/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:

```bash theme={null}
bricks buttress mcp-config --write
```

```json theme={null}
{
  "mcpServers": {
    "buttress-functions": {
      "url": "http://192.168.1.24:2080/functions/mcp",
      "headers": { "Authorization": "Bearer <workspace-access-token>" }
    }
  }
}
```

Point it at a specific host with `--url`, and drop the token for a server running unauthenticated:

```bash theme={null}
bricks buttress mcp-config --url http://buttress.local:2080 --no-token
```

<Warning>
  The written entry embeds a long-lived workspace access token. Treat `.mcp.json` as a secret and keep it out of version control.
</Warning>

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`](/cli/commands#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](/buttress/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".

<Warning>
  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.
</Warning>

To run functions on a server with no workspace binding, opt in explicitly:

```toml theme={null}
[functions]
allow_unauthenticated = true
```

<Warning>
  `allow_unauthenticated = true` lets anyone who can reach the port run every function. Only do this on a trusted network.
</Warning>

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.

```ts theme={null}
export const meta: ButtressAuthMeta = { mode: 'override' }

export default async function (request: ButtressAuthRequest, context: ButtressAuthContext) {
  if (request.workspaceAuth.authenticated) return true    // workspace tokens keep working
  const keys = context.config.api_keys                    // from [functions.config]
  if (Array.isArray(keys) && keys.includes(request.headers['x-api-key'])) return true
  return { ok: false, status: 401, error: 'Invalid or missing API key' }
}
```

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.

<Note>
  `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.
</Note>

## 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](/buttress/installation#verify).

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](/buttress/autodiscovery).

## 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.

| Sample                | Shows                                                                                 | Requires                                                |
| --------------------- | ------------------------------------------------------------------------------------- | ------------------------------------------------------- |
| `host-info.ts`        | The smallest useful function: a Node builtin plus `context.libs`                      | nothing                                                 |
| `summarize-text.ts`   | Calling this server's own LLM with `context.buttress.completion`                      | an LLM `[[generators]]` entry                           |
| `simple-rag.ts`       | Token chunking, embeddings, and `sqlite3` + `sqlite-vec` retrieval before the answer  | a chat LLM plus a GGML embedding `[[generators]]` entry |
| `transcribe-media.ts` | `context.spawn` (ffmpeg), the scratch directory, SSE progress, and STT                | `ffmpeg` on `PATH` plus an STT `[[generators]]` entry   |
| `text-to-speech.ts`   | `context.buttress.synthesize` paired with `context.fileUrl` for a downloadable result | an `onnx-tts` or `ggml-tts` `[[generators]]` entry      |
| `_auth.ts`            | Custom auth that keeps workspace tokens working and adds static API keys              | read the file header first                              |

`_auth.ts` is not a function — copying it changes how every `/functions` endpoint authenticates callers.

## Next steps

<CardGroup cols={2}>
  <Card title="Configuration" icon="gear" href="/buttress/configuration">
    The full TOML reference, including the generators functions call.
  </Card>

  <Card title="Workspace binding" icon="key" href="/buttress/workspace-binding">
    Bind the server so function calls require a workspace token.
  </Card>
</CardGroup>
