Per-User Runtime Goose Pattern¶
Guidance for applications that use Goose at runtime to stand up a background, per-user
research or learning team — and, just as important, a hard rule about where not to put
goose run.
agentteams' Goose support is design-time: the bridge emits thin stub recipes that point at your canonical source agents, and the quickstart runs the development orchestrator. This guide covers the separate question of running Goose inside a produced app, per end user, which agentteams does not scaffold.
Overview¶
This guide explains:
- The latency rule (read first): do not route latency-critical / interactive paths
through
goose run. - Where Goose does fit: a background, per-user research/learning tier.
- Per-user session isolation — named sessions, the SQLite session store, and context-rotation caveats.
- A per-user research recipe template.
- Invocation hygiene for parseable, bounded headless runs.
It is a pattern doc, not a scaffold — agentteams does not emit runtime Goose orchestration. A validated (spike-level) reference is cited under References.
The latency rule (binding for interactive paths)¶
Do NOT route a latency-critical or interactive path through
goose run.
goose run is a one-shot subprocess with a cold model-load cliff (measured at roughly ~28 s
cold-start, ~1.5–2 s warm, against a local Ollama runtime), no token streaming, and
non-deterministic tool loops (an agentic GOOSE_MODE=auto run can spin through tool calls
before returning). Those properties are fine for background work and wrong for a live turn:
| Property | Consequence for a live turn | Verdict |
|---|---|---|
| ~28 s cold model-load | The user's first turn stalls for half a minute | ✗ live |
| Subprocess-per-call | No warm in-process state; process spawn overhead each turn | ✗ live |
| No streaming | The user watches a blank box until the whole reply lands | ✗ live |
| Non-deterministic tool loop | Turn latency and shape vary run-to-run | ✗ live |
For the live conversation path, call the model directly (e.g. the local Ollama HTTP
endpoint the Goose backend is configured against) so you get warm state and streaming. Reserve
goose run for the background/agentic tier below.
Where Goose fits: a background per-user research tier¶
A per-user named Goose session genuinely accumulates history across invocations, which makes it the right unit for a background per-user research or learning team — a tier that runs between turns, proactively, or on demand, and whose latency the user does not directly wait on. The shape:
- one named session per user (per language / per topic area as needed),
- invoked headless, bounded, and parsed as JSON,
- every failure returns nothing — the live path never depends on it.
Per-user session isolation¶
Goose persists sessions in a local SQLite store (sessions.db). To keep users' histories from
bleeding together:
- Name sessions per user — e.g.
--name <app>-<account>-<lang>, with the account/language reduced to path-safe identifiers first (see the runtime-security guide — never interpolate raw user input into a session name). - Isolate the session store where the threat model requires it. Sharing one
sessions.dbacross all users is acceptable only if named sessions are strictly namespaced and you trust the process boundary; for real multi-user isolation, give each user (or tenant) its own store path. - Rotate context. A long-lived named session grows unboundedly; a background tier should cap or rotate the accumulated context (start a fresh session generation periodically, or prune) so a single user's history does not eventually blow the model's context window or slow every background run.
These are caveats to design for, not features agentteams provides. Validate your isolation and rotation before promoting a spike to real multi-user scale.
A per-user research recipe¶
Keep the recipe minimal and extension-free so a headless, bounded run returns a structured dossier instead of spinning an open-ended tool loop:
# .goose/recipes/<app>-research.yaml (illustrative)
version: "1.0.0"
title: "Per-user background research"
description: "One bounded research turn for a single user; returns a JSON dossier."
parameters:
- key: topic
input_type: string
requirement: required
- key: language
input_type: string
requirement: optional
default: "en"
# No extensions: the run should reason and return, not call tools.
extensions: []
prompt: |
Research {{ topic }} for a learner in {{ language }}.
Return ONLY a JSON dossier: {"summary": ..., "key_facts": [...], "subtopics": [...]}.
Invocation hygiene¶
Invoke headless with bounded, parseable flags:
goose run \
--recipe .goose/recipes/<app>-research.yaml \
--name "<app>-<account>-<lang>" \
-q --output-format json \
--provider ollama --model "<local-model>" \
--max-turns 3 \
--no-profile \
--params topic="<topic>" --params language="<lang>"
| Flag | Why |
|---|---|
-q --output-format json |
Machine-parseable output; strip any banner before the first {. |
--no-profile |
Loads only the recipe's extensions (none), so GOOSE_MODE=auto cannot spin the global developer/summon tool loop — the model reasons and returns the dossier. |
--max-turns N |
Hard upper bound on the agentic loop; keeps a background run finite. |
--name <per-user> |
Per-user session accumulation + isolation (above). |
--provider/--model |
Pin the local runtime explicitly so the background tier matches your app's configured backend. |
Then: parse the JSON (skip any leading banner), treat a non-zero exit / timeout / empty output as "no result," and continue — the background tier is best-effort by construction.
Read-only agents: the agentteams_readfs server¶
In Goose 1.37 the developer extension has exactly four tools: write, edit, shell and tree. The
only one that can print a file's contents is shell, which can also write and run anything. tree and
the analyze extension are read-only, but neither is confined to the workspace. A recipe that keeps
developer also gets analyze added automatically, and available_tools: [] means unrestricted.
(Spike, Goose 1.37.0, 2026-10-05; references/goose-tool-scoping-spike.md.)
The read-only file server (shipped in the package as agentteams/data/goose-readfs-mcp.py) is a stdlib-only
stdio MCP server that gives a read-only agent real read access with no write capability. Generated teams
get it at scripts/goose-readfs-mcp.py. Under write_policy: "orchestrator-only" it goes to
.agentteams/bin/goose-readfs-mcp.py instead: inside the control plane, which session sandboxes
write-deny, and checked by the runner against a pinned hash. It is always launched as python3 -I -S, so
PYTHON* variables, user site-packages and .pth files can't change what it runs:
| Tool | Does |
|---|---|
read_file |
Reads a text file with line numbers (offset/limit to page). Binary files are reported, not dumped |
list_dir |
Lists one directory |
find |
Finds files by glob |
grep |
Searches text files by regular expression |
stat |
Reports a path's type, size and modification time |
- Read-only by construction. No tool writes, and the source contains no write, delete, exec or network call. Three walls keep it that way:
- a source scan, which is a tripwire rather than a proof, and is mutation-tested against sixteen injected write or exec forms;
- code review;
- the pin in
references/enforcement-integrity.json. - Confined. Every path is resolved with
realpathand must stay under--root, so.., absolute paths and symlinks pointing outside are refused. - Files open
O_NOFOLLOW|O_NONBLOCK, and the handle must be the regular file that was checked (same device and inode). - Inside the root, case-insensitively, these are refused: VCS internals,
.env*(except.env.example,.env.sampleand.env.template), keys and keystores (*.pem,*.p12,*.pfx,*.jks),.ssh/,.aws/,credentials*, Terraform state and variables, and similar files. Add more with--deny GLOB. - A filesystem root (
/) is refused as--root. - Bounded, and says so. Each request is capped:
- bytes, lines and results;
- walked entries and depth;
greptime, at 5 seconds (it usesSIGALRM, so the limit applies only where the OS provides it);- file size for
grep.
A result that hit a cap says so, so a truncated search never reads as "no matches". - Residual. A hard link inside the root to a file elsewhere on the same filesystem reads like any workspace file. Creating one needs write access to the workspace.
A read-only recipe uses it instead of developer:
extensions:
- type: stdio
name: agentteams_readfs
cmd: python3
args: ["-I", "-S", "scripts/goose-readfs-mcp.py", "--root", "."] # .agentteams/bin/... under the switch
timeout: 60
available_tools: [read_file, list_dir, find, grep, stat]
Verified live on Goose 1.37.0 against a stub model:
- the recipe offers only the five read tools;
- read_file returns workspace content;
- a path outside the workspace is refused;
- a shell call fails with Tool 'shell' not found.
Set "goose_tool_scoping": "grant" in the brief to have agentteams emit this for every agent that declares
read or search, and ship the script with the team (see goose-privileges.md). The relative script
path assumes Goose starts in the project root. That was verified with goose run against an emitted
recipe; Goose Desktop has not been verified.
Best Practices¶
- Never put the live turn behind
goose run. Direct-call the local model for interactive replies; use Goose for the background tier only. - One named session per user; namespace or isolate the store. Sanitize identifiers before they reach a session name or path.
- Bound every headless run —
--max-turns, a subprocess timeout, and--no-profileto suppress the tool loop. - Fail open, depend on nothing. A background research run that errors, times out, or returns empty must return nothing and never block or corrupt the live path.
- Rotate context before scale. Cap or rotate per-user session history before promoting a spike to real multi-user use.
References¶
- Design-time Goose support (for contrast): the bridge emits stub recipes pointing at canonical source agents; the quickstart runs the development orchestrator — see the Goose privileges and backend switcher references for backend selection (local Ollama vs OpenRouter).
- Reference implementation (a validated spike, not a production integration): the LingoFriend
project's
.goose/recipes/lingofriend-research.yaml+knowledge/goose_research.pyrun one background research turn throughgoose runagainst a local Ollama runtime, using a per-account named session,--no-profileto suppress the tool loop, and-q --output-format jsonfor a parseable dossier. It is deliberately not wired into the conversation (latency-critical) path, and every failure returnsNone. Treat it as evidence the pattern works, not as a production design. - Related guidance: Runtime Security for Served Apps.