Preparing your system to run agentteams Goose teams¶
This guide gets a Goose user's machine ready to run and test the Goose teams agentteams generates or bridges — including the MCP extensions and an easily-accessible OpenRouter ⇄ local-GPU switch. It complements two existing docs: Goose Cloud Provider Guide (basic provider config) and the Goose Cheat Sheet (sessions, recipes).
Security first: never paste an API key into a recipe,
config.yaml, or any committed file. Always reference keys by environment variable. The patterns below keep secrets out of files.
1. Prerequisites¶
| Need | Check | Get it |
|---|---|---|
| Goose CLI ≥ 1.37 | goose --version |
https://block.github.io/goose/ |
| One LLM provider | see §2 | local GPU (Ollama) or OpenRouter |
| (MCP teams only) an MCP runner | uvx --version / npx --version |
brew install uv (for uvx) or Node (for npx) |
A generated team validates with no provider, but running it needs a provider.
2. Choose a provider¶
Goose reads GOOSE_PROVIDER / GOOSE_MODEL (and provider keys) from the
environment first, then ~/.config/goose/config.yaml. So your config.yaml is
the default, and environment variables are the override — which is exactly what
makes a switch easy.
2a. Local GPU (Ollama) — default, no key¶
# ~/.config/goose/config.yaml
GOOSE_PROVIDER: ollama
GOOSE_MODEL: qwen3.6:35b-a3b # any tool-calling model you have pulled
OLLAMA_HOST: http://localhost:11434
GOOSE_MODE: auto
ollama pull qwen3.6:35b-a3b (or your model) and you're ready.
2b. OpenRouter (cloud)¶
OpenRouter is OpenAI-compatible. Goose supports it natively. You need a
tool-calling-capable model id (a vision-only model will not work — Goose
requires tool calls), and the slug must use hyphens, not Ollama :tag syntax
(see the troubleshooting note in §6). Validate the model your config.yaml will
actually use:
curl -s https://openrouter.ai/api/v1/models \
| python3 -c "import sys,json;d=json.load(sys.stdin);m={x['id']:x for x in d['data']};\
mid='qwen/qwen3.6-35b-a3b';print(mid, mid in m and 'tools' in (m[mid].get('supported_parameters') or []))"
export OPENROUTER_API_KEY=….
3. The easily-accessible switch (OpenRouter ⇄ local GPU)¶
Drop this in ~/.config/goose/goose-backend.sh and source it from your shell rc.
It defines functions only (reads no secret at shell start), keeps config.yaml
as the local-GPU baseline, and reads the OpenRouter key by reference from a file
you control (e.g. a project .env) — never writing it anywhere.
# ~/.config/goose/goose-backend.sh — source from ~/.zshrc (or ~/.bashrc)
: "${GOOSE_OPENROUTER_ENV_FILE:=$HOME/path/to/your/.env}" # file holding OPENROUTER_API_KEY=...
_GOOSE_OR_MODEL_DEFAULT="qwen/qwen3.6-35b-a3b" # tool-capable; override via GOOSE_OPENROUTER_MODEL
_goose_or_key() { # extract ONLY OPENROUTER_API_KEY (no whole-file source; robust)
{ set +x; } 2>/dev/null
local k=""
[ -f "$GOOSE_OPENROUTER_ENV_FILE" ] && k="$(grep -m1 -E '^[[:space:]]*(export[[:space:]]+)?OPENROUTER_API_KEY=' \
"$GOOSE_OPENROUTER_ENV_FILE" | sed -E 's/^[[:space:]]*(export[[:space:]]+)?OPENROUTER_API_KEY=//; s/\r$//; s/^"(.*)"$/\1/; s/^'\''(.*)'\''$/\1/')"
[ -n "$k" ] || k="$OPENROUTER_API_KEY" # fall back to an already-set env var
printf '%s' "$k"
}
goose-backend() { # openrouter | local | status
{ set +x; } 2>/dev/null
case "$1" in
openrouter) local k; k="$(_goose_or_key)"; [ -z "$k" ] && { echo "no OPENROUTER_API_KEY found" >&2; return 1; }
export GOOSE_PROVIDER=openrouter GOOSE_MODEL="${GOOSE_OPENROUTER_MODEL:-$_GOOSE_OR_MODEL_DEFAULT}" OPENROUTER_API_KEY="$k"
echo "Goose → OpenRouter ($GOOSE_MODEL); key set (${#k} chars)";;
local) unset GOOSE_PROVIDER GOOSE_MODEL OPENROUTER_API_KEY; echo "Goose → local GPU (config.yaml/ollama)";;
status) echo "provider=${GOOSE_PROVIDER:-<config.yaml>} model=${GOOSE_MODEL:-<config.yaml>} key=$([ -n "$OPENROUTER_API_KEY" ] && echo set || echo unset)";;
*) echo "usage: goose-backend {openrouter|local|status} (or goose-or <args>)" >&2; return 2;;
esac
}
goose-or() { # PREFERRED: OpenRouter for ONE run; key stays in goose's process
{ set +x; } 2>/dev/null
local k; k="$(_goose_or_key)"; [ -z "$k" ] && { echo "no OPENROUTER_API_KEY found" >&2; return 1; }
GOOSE_PROVIDER=openrouter GOOSE_MODEL="${GOOSE_OPENROUTER_MODEL:-$_GOOSE_OR_MODEL_DEFAULT}" OPENROUTER_API_KEY="$k" goose "$@"
}
echo 'source "$HOME/.config/goose/goose-backend.sh"' >> ~/.zshrc # new shells get the commands
goose-or session # run on OpenRouter (key scoped to this run) — preferred
goose-backend openrouter # or switch the whole shell to OpenRouter
goose-backend local # back to local GPU
goose-backend status # see current
goose-or is preferred: it puts the key only in Goose's process, not your
interactive shell (smaller exposure). Use goose-backend openrouter when you want a
whole shell session on OpenRouter.
3b. The same switch, built into agentteams (persistent default)¶
agentteams can switch Goose's persistent default source/model by editing
config.yaml for you — no shell function required. It finds config.yaml the way Goose
does (asks goose info, falling back to $XDG_CONFIG_HOME/platform default), backs the
file up before writing, and preserves your extensions: block and comments.
agentteams --goose-show # current provider/model + resolved path + sources
agentteams --goose-source ollama # switch provider; applies that source's default model
agentteams --goose-source openrouter --goose-model qwen/qwen3-30b-a3b # provider + explicit model
agentteams --goose-model qwen/qwen3.6-35b-a3b # change only the model (current provider)
agentteams --goose-config PATH --goose-show # point at a non-default config.yaml
Each source carries a default model, so changing source without --goose-model uses
that source's default (built-ins: ollama → qwen3.6:35b-a3b,
openrouter → qwen/qwen3.6-35b-a3b). Add your own sources / override defaults in
~/.config/agentteams/goose-sources.json:
{ "sources": { "groq": { "default_model": "llama-3.3-70b-versatile", "key_env": "GROQ_API_KEY" } } }
Two complementary layers — know which one wins. Goose reads provider/model from the
environment first, then config.yaml. So:
agentteams --goose-source …sets the persistent default (config.yaml) — what plaingoose runand the VS Code task use.goose-or/goose-backend openrouterset an ephemeral env override that wins overconfig.yamlfor that shell.
If an env override is active, agentteams --goose-source warns that its config.yaml edit is
masked in that shell. It also rejects a model that doesn't match the provider's namespace
(an OpenRouter vendor/slug under ollama, or Ollama name:tag syntax under openrouter)
and reminds you to export a cloud provider's key. It never reads or writes the key itself.
For full OpenRouter model validation, run python scripts/goose-openrouter-preflight.py.
Newer Goose versions write a different config.yaml schema. goose configure on
recent Goose CLIs (1.37+) writes a nested providers: block with active_provider: at
the top level, instead of the older flat GOOSE_PROVIDER:/GOOSE_MODEL: keys.
--goose-show reads both schemas transparently. --goose-source/--goose-model read
both too, but only write the older flat schema — against a providers: config they
refuse (exit 2, no write, no backup) rather than add dead top-level keys Goose would never
read, and print the exact model: line to edit by hand under providers:\n <provider>:.
3c. Bridge a Goose team to/from other frameworks¶
Goose is a first-class bridge source and target — a .goose/recipes/ team can be
bridged to claude/copilot, and any source can be bridged to Goose.
# Bridge a Goose-native team OUT to Claude (auto-detected from the .goose/recipes path):
agentteams --bridge-from <project>/.goose/recipes --framework claude --output <target> --bridge-merge
# Per-agent stub recipes (opt-in): one thin .goose/recipes/<slug>.yaml per source agent,
# each a pointer to the canonical source. Default off; reserved/owned slugs are skipped and
# existing recipes are never overwritten. (For full per-agent recipes, use --convert-from.)
agentteams --bridge-from <project>/.github/agents --framework goose --output <target> \
--bridge-merge --target-host-features bridge:copilot-vscode-to-goose:subagents
The Goose-source inventory is read from each recipe's title:/description:; only .yaml
recipes are hashed for --bridge-check (build artifacts and junk are excluded, same as the
markdown-source path).
4. MCP servers (for teams built with MCP)¶
agentteams wires operator-specified MCP servers into Goose recipes opt-in:
- Direct build:
agentteams --framework goose --target-host-features goose:mcp … - Bridge:
agentteams --bridge-from <agents> --framework goose --bridge-merge --target-host-features bridge:<source>-to-goose:mcp …— emits.goose/recipes/bridge-orchestrator.yamlwith thedeveloper(CLI) extension by default, plus any opted-in servers.
What you must prepare on your machine to run those servers:
- An MCP runner. A
type: stdioextension runscmd(e.g.uvx,npx,python). Install what your servers use —brew install uvforuvx, or usenpx-based servers if you have Node. Ifuvxis missing, auvxserver will fail to launch. - Credentials by reference. Servers declare
env_keys: [NAME](never inline secrets). Export those env vars before running, e.g.export EXAMPLE_PG_DSN=…. - Nothing else — the
developer(CLI/shell) extension is built in and is always present in agentteams-generated/bridged recipes.
Only first-party, read-only, orchestrator-scoped servers are auto-wired into the
bridge recipe; others appear as # agentteams MCP: <id> not wired (<reason>)
comments (use a direct build for full per-agent MCP). See
mcp_emit.
5. Run a team¶
# Generated team (full recipes):
goose run --recipe .goose/recipes/orchestrator.yaml
# Bridged team (CLI + opted-in MCP guaranteed at session start):
goose run --recipe .goose/recipes/bridge-orchestrator.yaml
# Plain session (reads .goosehints → @AGENTS.md): just
goose session
goose-or (e.g. goose-or run --recipe …).
5a. Running through the resilient wrapper (optional)¶
Every team agentteams generates or bridges ships with scripts/goose-run-resilient.py (repo
root, alongside your other project scripts) — a thin wrapper around goose run that detects the
dead-turn symptom in §6 below (a turn that ends with no error and no output) and automatically
resubmits "Continue" in the same session, up to a retry cap:
python3 scripts/goose-run-resilient.py --recipe .goose/recipes/orchestrator.yaml
python3 scripts/goose-run-resilient.py -t "your prompt" --provider openrouter --model <model>
It forwards --provider/--model/any other goose run args unchanged and has no hardcoded
provider or model default — when you don't pass one, goose's own env/config.yaml resolution
decides, same as calling goose run directly. This is an addition, not a replacement — the
bare goose run commands above keep working exactly as documented, and the wrapper only helps
if a session dies silently in the specific way §6 describes; it does not cover goose session or
the VS Code extension's interactive path. Its dead-turn detection was confirmed against
OpenRouter + a reasoning-capable model; on other providers (including local Ollama) it fails
closed — if it can't confidently classify a turn as dead, it takes no action, so at worst it's a
no-op passthrough, never a false "Continue."
5b. Verified delegation (the last Phase-1 sign-off)¶
A generated direct-build orchestrator carries a sub_recipes: block (the
bridge entry recipe is a pointer with none), so its recipe is the one that
exercises native delegation. To verify end-to-end that the orchestrator actually
routes/delegates — not just that the recipe validates — run its W6 probe prompt
non-interactively against a real provider:
# requires a configured OpenRouter key (export OPENROUTER_API_KEY=… or goose-or)
GOOSE_MODE=chat goose-or run \
--recipe .goose/recipes/orchestrator.yaml --no-session --max-turns 4
PASS = the orchestrator states its role and, for "produce a deliverable for this
team", names the correct workflow (Workflow 1: Produce a Deliverable) and the
first agent it routes to (@primary-producer, the primary_producer
sub_recipe) — observable delegation to a named child session.
Judge the OUTPUT, not the exit code, and distinguish the two error classes:
| Class | goose exit | Meaning |
|---|---|---|
Missing key (no OPENROUTER_API_KEY) |
1 | Fails at config-resolution, before any LLM call — a setup problem, not a delegation result. |
Provider error past config-resolution (not a valid model / 400, 401/unauthorized, hit --max-turns) |
0 | goose exits 0 even on these — classify by output (model / auth / inconclusive), never the exit code. |
The repeatable check is tests/test_goose_live_delegation.py. It is
skip-by-default: a mandatory @pytest.mark.skipif skips it whenever
OPENROUTER_API_KEY is not resolvable (env or GOOSE_OPENROUTER_ENV_FILE) — and
when goose is absent — so CI / a keyless repo stay offline-green. With a key it
runs the probe above and asserts delegation; a model/auth fault or a max-turns
miss is treated as environment/transient (skip), not a wiring regression. The key
is resolved by reference and passed only into the goose subprocess — never logged
or serialized.
Verified 2026-06-22 against
openrouter/qwen/qwen3.6-35b-a3b:tests/test_goose_live_delegation.pyPASSED (109s) — the generated orchestrator ran on OpenRouter and delegated to the namedprimary_producersub_recipe. This closes the master integration plan's final Phase-1 sign-off (the previously "not yet run" live delegation).
6. Validate & troubleshoot¶
| Symptom | Fix |
|---|---|
| Query stops early & quickly on OpenRouter / "not a valid model ID" | Your GOOSE_MODEL uses Ollama tag syntax (model:tag). On OpenRouter : means a variant (:free), so e.g. qwen/qwen3.6:35b-a3b (colon) doesn't exist — use the hyphen slug qwen/qwen3.6-35b-a3b. Run python scripts/goose-openrouter-preflight.py for the exact fix; --fix applies it (backup first). On a newer providers:/active_provider: config.yaml (§3b), --fix refuses instead of guessing — it prints the exact model: line to edit by hand. |
A turn silently ends with no error and no output — not the above (your model id is valid), goose doctor is clean, and nothing appears in ~/.local/state/goose/logs/cli/<date>/*.log |
Different, deeper failure: the model traps its own tool call inside <tool_call> text in the reasoning stream instead of the structured tool-call field, so nothing actionable reaches goose (finish_reason: stop, no error anywhere). This is largely a matter of which OpenRouter backend served you — on one real payload, measured leak rates ranged from 0/12 to 3/12 across backends serving the same model. Best available mitigation (not a fix): select the backend via scripts/goose-openrouter-route-proxy.py + OPENROUTER_HOST — see goose-cloud-providers.md. This covers all goose surfaces including VS Code. It does not eliminate the failure — on 2026-07-24 the leak recurred on Morph, one of the backends measured 12/12 clean, with routing verifiably active; an 8-run end-to-end test still saw 2 silent dead turns. CLI-only secondary: scripts/goose-run-resilient.py (§5a) detects the dead turn and auto-continues — but it wraps goose run, so it does not protect the VS Code extension (which runs goose acp). Note OPENROUTER_PARAMETERS is inert in Goose 1.37.0 and cannot be used for this. |
| The agent scrapes a homepage, floods its own context with navigation HTML, and never finds the answer | It has no search tool in its tool list — web_scrape needs a URL it already knows, so it guesses one. Measured 2026-07-24: a single scraped homepage was 29,654 chars, 54% of the whole conversation, and contained none of the answer (goose's 50 KB tool-output limit then truncated it to a temp file). Use python -m agentteams.research search "<query>" to find the page and ... fetch "<url>" to get extracted text rather than raw HTML. Bridged Goose teams built before 2026-07-24 don't mention this module in their AGENTS.md — re-run agentteams --bridge-from … --framework goose --bridge-merge to add it. |
agentteams.research search returns [] for a long, specific query |
Not "no results": DuckDuckGo answers a challenged request with HTTP 202 and an interstitial page, which parses to nothing. Fixed 2026-07-24 — it now retries once with a broadened query and prints a note: to stderr explaining what happened. If you see that note, the empty list is a block, not an absence. Short keyword queries are challenged far less. |
agentteams.research fetch returns a few hundred chars of menus |
--max-bytes caps the download, and the old 40 KB default stopped inside a large page's <head>/navigation — 342 chars and zero body content from a Wikipedia article, with no error. Default is now 400,000. Raise --max-bytes for very large pages; --max-chars separately bounds what enters your context. |
goose recipe validate <f> fails |
check version: "1.0.0", non-empty instructions:, no model: key |
| "No provider/model configured" | set a provider (§2/§3) — env override or config.yaml |
| OpenRouter 401 | OPENROUTER_API_KEY unset/invalid; goose-backend status to check |
| Model errors / no tool calls | your OpenRouter GOOSE_MODEL isn't tool-capable — pick a tool model (§2b) |
| stdio MCP server won't start | install its runner (uv/uvx or npx); export its env_keys creds |
| validate a recipe before running | goose recipe validate .goose/recipes/<slug>.yaml |
Recover a broken project tracker and local route proxy¶
Two independent faults can appear together:
Run the bounded recovery utility first from the AgentTeamsModule repository:
python3 scripts/goose-recover.py --check # diagnose only
python3 scripts/goose-recover.py # repair/start when safe
The check performs no writes, process signals, credential reads, or non-loopback requests. It
returns 0 when both components are healthy, 1 when a recognized repair or proxy start is
needed, 2 when the state is unsupported or unsafe to change, and 3 for an operating-system
or subprocess failure. Normal mode can repair only the observed tracker corruption shape: a
schema-valid projects object followed by : null and extra closing braces. It refuses
truncated, non-UTF-8, structurally invalid, or otherwise unknown content.
When port 8791 is empty, normal mode starts
scripts/goose-openrouter-route-proxy.py detached with that proxy's built-in provider defaults.
Those defaults may differ from a prior narrowed --only command; use the manual command below
when exact backend selection matters. When the tracked route proxy is already healthy,
--restart preserves its exact command. It refuses an empty port, an unknown listener, a
process/health identity mismatch, or a proxy that cannot answer its local /healthz endpoint.
An older proxy started before this endpoint was added forwards /healthz upstream and commonly
returns HTTP 403; treat that as a legacy process requiring the verified-owner manual migration
below, not as proof that an unknown listener is safe to terminate.
The process identity check and SIGTERM remain separate operating-system operations, so an
unavoidable PID-reuse race exists despite the immediate recheck.
The utility prints the exact mode-0600 tracker backup and proxy log paths it creates. It does
not install a login service, so a reboot can require another normal invocation.
Failed to parse projects.json filemeans Goose cannot decode~/.local/share/goose/projects.json. In the observed failure, a complete valid object was followed by: nulland extra closing braces. Back up the file, retain only the first valid top-level object, then validate it with both a general JSON parser and Goose itself:
tracker="$HOME/.local/share/goose/projects.json"
cp -p "$tracker" "$tracker.bak-$(date +%Y%m%d-%H%M%S)"
python3 - "$tracker" <<'PY'
import json
import os
from pathlib import Path
import sys
path = Path(sys.argv[1])
value, end = json.JSONDecoder().raw_decode(path.read_text())
if not isinstance(value, dict) or not isinstance(value.get("projects"), dict):
raise SystemExit("refusing repair: first JSON value is not a Goose project tracker")
temporary = path.with_suffix(path.suffix + ".tmp")
temporary.write_text(json.dumps(value, indent=2) + "\n")
os.replace(temporary, path)
PY
jq empty "$tracker"
goose projects
goose projects must show the project menu instead of a parse error. If it does not, restore
the timestamped backup rather than making further speculative edits.
- An immediate connection refusal on port
8791meansOPENROUTER_HOSTpoints to the local route proxy but nothing is listening. The proxy is opt-in and nonpersistent: it is not a login service and does not survive a reboot. Confirm the configuration and listener, then restart it with a backend that currently serves the configured model:
grep '^OPENROUTER_HOST:' ~/.config/goose/config.yaml
lsof -nP -iTCP:8791 -sTCP:LISTEN
python3 scripts/goose-openrouter-preflight.py --providers <openrouter-model>
python3 scripts/goose-openrouter-route-proxy.py --port 8791 --only "<current-backend>"
If goose-recover.py --restart reports proxy health endpoint failed, first confirm the PID
and command shown by lsof -nP -iTCP:8791 -sTCP:LISTEN and
ps -ww -p <pid> -o command=. Stop it with kill <pid> only when it resolves to one of the
tracked route-proxy script, then run python3 scripts/goose-recover.py. Do not kill an
unknown listener. Avoid restarting while an active Goose turn is using port 8791; the
in-flight request will be interrupted.
Keep that process running. Alternatively, remove OPENROUTER_HOST from config.yaml and
restart Goose to use OpenRouter directly without backend pinning. The proxy is a reliability
layer for provider selection, not a universal OpenRouter connectivity requirement.
Ollama tag vs OpenRouter slug — the #1 early-stop trap. The same model is addressed differently per provider: Ollama uses
name:tag(e.g.qwen3.6:35b-a3b, correct in §2a), OpenRouter usesvendor/model-variantwith hyphens (e.g.qwen/qwen3.6-35b-a3b). Pasting the Ollama tag into the OpenRouterGOOSE_MODELmakes OpenRouter reject the model and the query stops before doing any work.
Verify your OpenRouter model and reproduce/diagnose the early-stop in one command
(static check needs no key; --live runs an end-to-end goose tool probe):