fuse is a terminal-based, multi-model AI agent harness: an interactive shell that drives LLMs (via a LiteLLM gateway) with tools, a hardened subagent runtime, human-in-the-loop permissions, MCP integration, and an embedded skill system.
make build # build the fuse binary
make install # install to your GOPATH/bin
go test ./... # run the test suiteConfiguration lives in ~/.fuse/config.yml (with an optional per-repo
.fuse.local.yml override). See internal/config for the full schema.
loop-serve-net can independently expose Prometheus metrics, OpenTelemetry traces, and payload-free structured logs. The source-controlled development-only reference stack provisions Prometheus, Grafana, an OTEL Collector, and Tempo (intentionally not Loki). See observability operations for cardinality limits, rotation, authenticated replay, and the Grafana-to-Tempo workflow.
/research <query> produces a cited research report. It diversifies the
question into several facets, fans out one subagent per facet to search the web
and fetch the most promising pages, deduplicates sources, and synthesizes a
single markdown report with [N] citation markers and a numbered source list.
The flow is driven by the existing subagent runtime and an embedded research
skill; a user skill named research shadows the built-in one. The skill is
available in both the interactive shell (via /research) and one-shot mode: a
one-shot task whose intent matches the skill's description (e.g. fuse "do a deep research on ...") loads and follows the skill automatically, because the
model is instructed to call the skill tool first for any matching request.
Two built-in tools back the flow: web_search (query a search provider) and
web_fetch (fetch a URL and extract the main article text, with a robots.txt
gate on by default).
You bring your own search API key. Provider resolution happens at first use and fails loudly if nothing is configured, in this order:
- an explicit
research.providerin config (brave|tavily|custom) - the
BRAVE_SEARCH_API_KEYenvironment variable - the
TAVILY_API_KEYenvironment variable - a configured
[research.custom]block - otherwise, a clear error naming all of the setup paths above
Brave Search (primary, recommended). Set BRAVE_SEARCH_API_KEY. Sign up at
api-dashboard.search.brave.com (a card is required; roughly $5/month of free
credits). fuse uses the Brave Search engine that also powers the web search in
some other AI assistants; here you supply your own key directly. This README's
Brave Search mention also serves as the attribution Brave's free credits ask
for.
Tavily (alternative). Set TAVILY_API_KEY. Tavily returns extracted page
content alongside results, which lets the research flow skip a separate fetch
for sources whose returned content already suffices.
Custom / self-hosted (e.g. SearXNG). Configure [research.custom] with a
URL template. For a SearXNG instance only the url is needed - the JSON field
mappings default to SearXNG's format=json response shape:
research:
custom:
url: "https://searx.example.com/search?q={query}&format=json"permissions:
mode: smart # off | prompt-all | smart | auto (default: smart)
session_allow: true # whether the [s]ession "allow for this session" option appears
auto_approve: [] # per-segment allow patterns (see note below): "bash:git *", ...
always_prompt: [] # patterns demoted to always-prompt, e.g. "bash:git push*"
disabled: [] # tool names fully disabled (never runnable), e.g. "web_fetch"
auto: # auto-mode surface (only consulted when mode: auto)
classifier_model: deepseek-flash # alias that judges gray-area commands; NEVER a chat alias
deny: [] # extra always-deny per-segment patterns, e.g. "bash:npm publish*"
ask: [] # extra always-ask per-segment patterns (override an allow)
fetch_deny: [] # host-glob floor: matched web_fetch hosts are denied before
# the classifier is ever consulted, e.g. ["*.internal.example"]
fetch_ask: [] # host-glob floor: matched web_fetch hosts are forced to a
# human ask before the classifier is ever consulted
allow_push: false # true = `git push` auto-approves; false = it routes to the classifier
write_roots: [] # extra dirs scoped like the workspace (e.g. ["/tmp"]); the
# per-session scratch dir under ~/.fuse/tmp/ is always included
# Per-project overrides (user-owned ~/.fuse/config.yml ONLY — see below).
# Absolute project path -> a permissions subtree that applies when the shell
# runs inside that path. "auto here, not there" without weakening any repo.
projects:
/Users/me/work/trusted-app:
permissions:
mode: auto # this project starts in auto; others keep the global default
auto:
classifier_model: deepseek-flash
`mode: auto` runs commands without a human prompt when they are provably safe.
It is layered: a bash command is split into its simple-command **segments**
(across `&&`, `||`, `;`, `|`, newlines, and the body of `bash -c`/`sh -c`), and
**each segment is evaluated independently** — static deny/ask rules first, then
the read-only safe list, then path/egress heuristics, and only genuinely
ambiguous segments reach the `classifier_model`. Deny beats ask beats allow; a
command that cannot be parsed fails closed.
**`web_fetch` known-good hosts auto-approve with no prompt and no classifier
call.** A hardcoded seed of reputable hosts (docs sites, code hosting, etc.)
plus the bundled top-sites popularity set auto-approve `web_fetch` at the
floor, before the classifier is ever consulted — this is a zero-review GET of
any path/query under that host. `auto.fetch_deny` and `auto.fetch_ask` sit
ahead of that floor and override it: a host matching `fetch_deny` is always
denied and a host matching `fetch_ask` always forces a human prompt,
regardless of the seed or popularity list.
**`auto_approve` (and `auto.deny`/`auto.ask`) are per-segment, not first-token
prefixes.** A pattern only approves the segment it matches, so `git status &&
rm -rf ~` is **not** auto-approved by `bash:git *`: the `git status` segment
matches and the `rm -rf ~` segment does not, and one un-approved segment denies
the whole command. There is no way to whitelist a leading `git` into approving a
trailing `rm`. Wrapping (`sh -c "rm x"`), command substitution (`$(...)` /
backticks), env-assignment prefixes (`FOO=bar cmd`), and path-qualified argv0
(`/usr/bin/rm`) all fail closed rather than slip past a prefix match.
**Trust boundary.** The permission-*loosening* keys — `mode`, `session_allow`,
`auto_approve`, and the entire `auto` block — are honored **only** from the
trusted `~/.fuse/config.yml`. A repo-plantable `.fuse.local.yml` cannot weaken
the gate: those keys are ignored there (with a startup warning), so a checked-in
file cannot flip a clone into `auto` mode or self-approve. Only the *tightening*
keys `always_prompt` and `disabled` take effect from `.fuse.local.yml`. Set
anything that grants trust in your own `~/.fuse/config.yml`.
**Per-project trust (`projects:`).** To grant "auto here, not there" you can key
a `permissions:` subtree by **absolute project path** under a `projects:` map.
When the shell starts inside a path that equals — or is a descendant of — one of
those keys, that entry's permission subtree is merged in as **trusted** (the full
subtree, `mode` and the whole `auto` block included), layered above the global
`permissions:` and below the tighten-only `.fuse.local.yml`. When several keys
are ancestors of the current directory, the **longest (most specific) key wins**;
matching is by whole path segments, so a key `…/b` never matches a directory
under `…/bc`, and symlinked working directories are resolved to their real path
before matching. Because this is pure loosening, the map is honored **only** from
your own `~/.fuse/config.yml` — a `projects:` block planted in a repo's
`.fuse.local.yml` is ignored and named in the startup warning, exactly like every
other loosening key, so a checked-in file still cannot flip a clone into `auto`.
**Switching mode in-session.** `permissions.mode` is only the **startup
default** — the permission mode is a live session surface you can flip without
restarting, and the interactive shell shows the active mode in its status line
(e.g. `mode: auto`):
- **Shift+Tab** toggles between the two everyday postures, `smart` ⇄ `auto`.
From `prompt-all` or `off`, the first Shift+Tab lands on `smart`, and
Shift+Tab thereafter toggles `smart` ⇄ `auto`.
- **`/mode`** (bare) prints the active mode and lists all four options;
**`/mode <name>`** sets any of `smart`, `auto`, `prompt-all`, `off` directly.
An unknown name is rejected with a usage line and leaves the mode unchanged.
A switch takes effect on the **next turn** — the gate is rebuilt per turn at the
current session mode, so a flip into `auto` immediately governs the following
tool calls (no restart, no stale gate). If you switch into `auto` but the
gateway has no classifier configured, the status line marks the mode
**degraded** (`mode: auto` with a degraded marker) — the deterministic rules and
read-only safe list still apply, but gray-area commands fail closed to a prompt
rather than reaching a classifier.
### The `[research]` config block
```yaml
research:
provider: "" # "" = auto (Brave env -> Tavily env -> custom); or brave | tavily | custom
max_queries: 5 # facet count hint for the research flow
max_results: 5 # results per web_search call
max_content_kb: 50 # web_fetch content truncation (KB, word-boundary)
respect_robots: true # honor robots.txt in web_fetch; set false for dev/testing
custom:
url: "" # template with {query} / {count}; empty = not configured
headers: {} # optional request headers
results_path: results # dotted path to the results array (SearXNG default)
title_field: title # per-result field mappings (SearXNG defaults)
url_field: url
snippet_field: contentagents:
max_spawns: 64 # tree-global spawn budget: the total number of child
# agents one root turn may create, ever. The runtime
# appends a line like `agent budget: 7/64 used (57
# remaining)` to every spawn_agent result so the model
# can stop before it fans out too wide, and refuses the
# spawn outright once the ceiling is reached. Bounds
# runaway fan-out in the research flow. Once the budget is
# exhausted the spawn_agent tool is stripped from the
# model's schema for the rest of the session (a permanent
# brake, since the tree is append-only).
max_concurrent: 16 # live-concurrency cap: the number of child agents that
# may RUN at once, bounded by a semaphore, independently
# of the total max_spawns budget. When the active child
# count (running + pending) reaches this cap the
# spawn_agent tool is stripped for that turn and reappears
# once children finish (a reversible brake). A negative
# value is clamped back to the default.The research flow is emergent and prompt-driven: the model diversifies a
question into facets, fans out one subagent per facet, and each child searches
and fetches on its own. That is hard to watch in the interactive shell (it
scrolls past). research-probe runs the real flow — the
embedded research skill, the real web_search/web_fetch tools against your
configured provider, the real spawn_agent fan-out, talking to the live
gateway — headless and fully recorded, then prints an inspectable digest:
export TAVILY_API_KEY=... # or BRAVE_SEARCH_API_KEY
fuse research-probe --model kimi "What is Litestream and how does it back up SQLite?"It prints the spawn tree (root + one node per facet, with status), the census
of searches and fetches, every unique search query and fetched URL, and the
root's final synthesized report — or a clear "did not synthesize a report" line
when the flow fans out but never converges. Flags: --model <alias> picks the
driver model, --trace <file> also writes the raw gateway request/response
JSON, and --timeout <dur> bounds the whole run (default 3m). Nothing about the
agents is faked — the recorder is just an agent.Renderer layered over the
production wiring, so what you see is exactly what /research does.
fuse loop-serve-net exposes the same policy-free multi-loop runtime that
loop-server serves over stdio, but over the network as a
Connect service (fuse.loop.v1, IDL in
proto/fuse/loop/v1/loop.proto). It is browser-reachable over HTTP/2 with no
proxy and speaks Connect, gRPC, and gRPC-Web (served over h2c):
fuse loop-serve-net --addr 127.0.0.1:8787Unlike the stdio loop-server and the local CLI paths, this networked binding
requires a bearer token on every request — identity and authorization live at
the Connect edge, never in the policy-free runtime seam. Present the credential as
an Authorization: Bearer <token> header; a missing or unknown token is rejected
with Unauthenticated before the handler runs.
The token→principal map and the owner-liveness lease TTL come from the trusted
~/.fuse/config.yml loop_server: block (a credential-bearing surface — a
repo-plantable .fuse.local.yml cannot mint tokens):
loop_server:
lease_ttl: "30s" # owner-liveness lease; empty ⇒ 30s runtime default
auth:
- token: s3cr3t-alice # the bearer credential a client presents
tenant: acme # isolation boundary; empty ⇒ the _default tenant
subject: alice # authorization subject, recorded as a loop's owner
observability_operator: true # grants global logging reload/reopen
- token: s3cr3t-bob
tenant: acme
subject: bobEach principal resolves to a tenant (the isolation boundary every store/registry
op is keyed by) and a subject (recorded as a loop's owner). Authorization is then:
- Tenant spoof — a request whose wire
tenantdiffers from the token's tenant is rejectedPermissionDenied. - Cross-tenant — reaching a loop under a different tenant returns
NotFound(the tenant-scoped resolve simply misses it; a tenant cannot even learn another tenant's loop exists). - Cross-owner — a same-tenant caller that is not the loop's recorded owner is
rejected
PermissionDenied.
The lease TTL is the owner-liveness window: the owning instance renews the
lease while a loop is live, and a loop whose lease has expired is treated as
abandoned and re-ownable by a cold instance on reconnect (so a crashed owner does
not strand a loop). Reconnecting a dropped Observe is just re-opening
Observe(from_seq) with the same token — the stream dedups replay/live at the
watermark, so there is no loss or duplication across the handoff.
If loop_server.auth is empty, a single built-in dev token (fuse-dev-token,
mapped to the _default tenant) is synthesized so local development works out of
the box — the server still authenticates every request; it never runs open.
Configure real tokens for any shared or deployed server.
The service is three RPCs:
StartLoop/Send— unary. The authoritative tenant is the authenticated token's; the wiretenantfield, when set, must match it (a mismatch is a spoof, see above).Observe(from_seq)— server-streaming history-then-live: it replays durable history sincefrom_seq, then live-tails, deduping the replay/live overlap at the watermark and flagging agapwhen a sequence hole is detected. A reconnecting client (any instance, resolved cross-instance via the durable store) simply re-opensObservefrom its last-seen seq — this single stream subsumes both the live tail and catch-up replay. Idle streams receive periodickeepaliveframes so a parked loop survives a gateway idle timeout.
The wire stubs are generate-and-commit (make proto, requires buf +
protoc-gen-go/protoc-gen-connect-go on PATH and cd proto && npm ci for the
TS protoc-gen-es plugin); committed Go stubs live in internal/loopwire/v1
and TS stubs in proto/gen/ts.
This replaces the earlier JSON-over-WebSocket wire (change #48, ADR-0032
superseded): there is no /ws or /loops/{id}/events route anymore.
MCP tool execution is attached on every loop binding — the shell, the one-shot
CLI, the research probe, and both loop-servers — through one composition-root helper,
mcpAttach(cfg, w) in cmd/fuse. It resolves, together, the identity-propagation
egress seam (mcp.WithCredentialSource, when an identity-propagating MCP server is
configured — change #52) and the complete-mediation TargetMediator gate, so no
binding can wire a manager without also wiring the egress credential seam and the
target-allowlist gate. A misconfiguration is logged loud at startup, never silent.
On the loop-server paths this is first-class and per-loop: each hosted loop
gets its own mcp.Manager, its tools registered into that loop's own cloned
tool registry, and the manager closed at loop teardown — two concurrent loops never
share a manager, a registry, or a credential. Crucially, the loop-server threads the
real authenticated principal the Connect edge resolved (sub = the initiating
user, act = fuse) into the egress, so each outbound MCP tools/call presents a
short-lived, audience-bound, per-principal delegation token minted for that user
and tenant — never a shared static token and never the _default shim. The minted
token lives only on the outbound transport header: it never appears in the event
stream, the durable store, logs, or model context.
The identity travels as a context value stamped by the composition root
(toolidentity.WithPrincipal) through the runtime's policy-free LoopContext /
LoopTeardown seams — internal/runtime imports no auth, MCP, or cmd/fuse type
(ADR-0030 preserved). A permanent CI acceptance lane
(cmd/fuse/loop_serve_net_rentals_acceptance_test.go) drives two authenticated
principals concurrently through the loop-server binding against a real stateful
rentals MCP server (internal/mcpdemo/rentals, importable by Wander), under -race,
asserting the full #52 checklist — distinct per-principal tokens, downstream 403 as a
distinguishable tool error, per-principal write isolation, wrong-audience rejection,
complete mediation of an undeclared target even under auto-approve, and no credential
leak — so a regression kills the build.