I'm Aman Kumar. goose is an open-source agent that runs in your terminal (and as a desktop app). It started at Block and now lives under the Agentic AI Foundation as aaif-goose/goose. It reads files, runs shell commands and calls tools through MCP extensions, and it can use almost any model, including anything behind an OpenAI-compatible API: a vLLM box, a company proxy or a hosted gateway.
There are two ways to point it at such an endpoint, and they treat the URL differently. I tested both with the goose 1.55.0 Linux CLI against a small local mock server that logged every request path, header and body. The examples use APIClaw, an OpenAI-compatible gateway I build, but nothing here depends on it. Use your own base URL and the exact model ID your provider lists.
Option 1: the built-in openai provider with OPENAI_HOST
The quickest setup is environment variables:
export GOOSE_PROVIDER=openai export GOOSE_MODEL=YOUR_MODEL_ID export OPENAI_HOST=https://apiclaw.biz # host only, no /v1 export OPENAI_API_KEY=YOUR_API_KEY goose run --no-session -t "say hi"
goose builds the URL as OPENAI_HOST plus OPENAI_BASE_PATH, and the base path defaults to v1/chat/completions. My mock saw POST /v1/chat/completions with Authorization: Bearer YOUR_API_KEY.
The trap is putting /v1 on the host. Almost every other tool wants a base URL that ends in /v1, so it's natural to paste https://apiclaw.biz/v1 here. With OPENAI_HOST=http://127.0.0.1:8765/v1, goose requested /v1/v1/chat/completions and /v1/v1/models. A trailing slash (.../v1/) gave the same doubled path. A real server answers that with a 404, which looks like a bad key or a wrong model until you see the path.
If your provider's path isn't /v1/..., set the base path instead of changing the host. With OPENAI_BASE_PATH=api/v1/chat/completions, requests went to /api/v1/chat/completions and the model list to /api/v1/models.
The same keys work in ~/.config/goose/config.yaml, which is nicer than exporting them in every shell:
GOOSE_PROVIDER: openai GOOSE_MODEL: YOUR_MODEL_ID OPENAI_HOST: https://apiclaw.biz OPENAI_BASE_PATH: v1/chat/completions GOOSE_CONTEXT_LIMIT: 128000 GOOSE_MAX_TURNS: 25
goose keeps the API key in the system keyring. On a headless machine without one, set GOOSE_DISABLE_KEYRING=1, and goose reads secrets from ~/.config/goose/secrets.yaml instead (for example OPENAI_API_KEY: YOUR_API_KEY). In my test the key from that file was the one in the Authorization header. When the key was missing entirely, goose printed a keyring hint pointing to its troubleshooting docs, not a clear "no API key" message.
Option 2: a custom provider file
If you also use OpenAI itself, overwriting the openai provider's host is awkward. goose supports declarative providers instead. These are JSON files in ~/.config/goose/custom_providers/, the same format goose uses for the providers it ships (DeepSeek, Groq, Mistral and others). goose configure can create one interactively, or you can write it yourself:
{ "name": "mygateway", "engine": "openai", "display_name": "My Gateway", "description": "OpenAI-compatible gateway", "api_key_env": "MYGATEWAY_API_KEY", "base_url": "https://apiclaw.biz/v1", "models": [{ "name": "YOUR_MODEL_ID", "context_limit": 128000 }], "supports_streaming": true }
Then use GOOSE_PROVIDER=mygateway and provide MYGATEWAY_API_KEY as an environment variable or in secrets.yaml.
The interesting part is that base_url here is forgiving in a way OPENAI_HOST is not. I tried http://127.0.0.1:8765, http://127.0.0.1:8765/v1 and http://127.0.0.1:8765/v1/chat/completions, and all three produced POST /v1/chat/completions. A custom prefix survived too: .../api/v1 and plain .../api both went to /api/v1/chat/completions. So with a custom provider you can paste the base URL your provider documents and it just works.
With the model listed in models, goose made no /models call at all. With a model name that wasn't in the list, it first fetched GET /v1/models and then sent the request with that name anyway. The built-in provider did the same GET /models lookup until I set GOOSE_CONTEXT_LIMIT.
What goes on the wire
Every request was a streamed Chat Completions call: "stream": true with "stream_options": {"include_usage": true}. No temperature was sent unless I set GOOSE_TEMPERATURE (with GOOSE_TEMPERATURE=0, every request carried "temperature": 0.0). If your server doesn't stream, set "supports_streaming": false in a custom provider file.
Two details matter if your plan counts requests:
- Each new session starts with an extra request. Before the actual task, goose sent a short call with a system prompt beginning "Generate a short title (four words or less) for this conversation" and no tools.
--no-sessiondidn't skip it. So a one-linegoose runcost two requests, not one. - Every agent request carries the full tool list. On a fresh install with default extensions, that was 17 tool schemas (
shell,edit,write,treeand others) plus a system prompt of several thousand characters. Models with weak tool calling struggle with that. goose has a toolshim option (GOOSE_TOOLSHIM) aimed at those models, and disabling extensions you don't need shrinks every request.
The 1,000-turn default
This is the one I'd fix before anything else. I changed the mock so that every request with tools got the same reply: a tool call to tree on /tmp. That's a stand-in for a model stuck in a loop.
- With
--max-turns 3, goose made 3 tool requests (plus the title call), then printed "I've reached the maximum number of actions I can do without user input. Would you like me to continue?" - With no limit, I killed it after 120 seconds. By then it had sent 562 requests. 547 were tool turns and 15 were summary calls that start "An llm context limit was reached...", which goose sends to compact history as the conversation grows.
The source explains it: DEFAULT_MAX_TURNS is 1000 unless GOOSE_MAX_TURNS or --max-turns says otherwise. On per-token billing, a stuck loop costs money. On a plan with a daily request limit, it can use up the day's quota before you notice.
--max-tool-repetitions sounds like the right guard, but in my test (with 2) the identical tree call still ran on every turn until --max-turns 10 stopped it. My mock gave each call a new ID, which may be why. Either way, a turn limit is the guard I trust.
The compaction calls are also why context_limit should match your real model. goose decides when to summarize from the limit it believes, and every summary is another request.
Debugging tip
goose writes the last few raw request bodies to ~/.local/state/goose/logs/llm_request.N.jsonl. Each entry has the model config and the full messages and tools that went out. In my files, the API key wasn't in them. When something odd happens, that's faster than guessing.
Checklist
OPENAI_HOSTis the host only. Change the path withOPENAI_BASE_PATH.- Prefer a custom provider JSON file if you also use OpenAI, or if you want to paste a normal
/v1base URL. - Set
GOOSE_DISABLE_KEYRING=1and usesecrets.yamlon headless machines. - Set
GOOSE_CONTEXT_LIMIT(orcontext_limitper model) to the real value. - Set
GOOSE_MAX_TURNS(I use 25 for interactive work and less for scripts) before running anything unattended. - Expect one extra title request per session.
Disclosure: I build APIClaw, the OpenAI-compatible gateway used in these examples (flat monthly plans; crypto on the site, other payment methods through support). Everything above applies to any compatible endpoint.