Obrew CLI
Local AI models as a headless agent CLI. obrew-cli runs GGUF models on your machine through llama.cpp's llama-server, gives the model a fenced set of tools plus any MCP servers you point it at, and streams what happens as JSON lines.
The Python obrew-engine is being ported here in phases. No Python, no webview, no REPL: one Bun binary.
Quickstart
bun install
bun run dev login # installs llama-server + pulls the default model
bun run dev exec "say hi" # human output
bun run dev exec --json "say hi" # one JSON event per line
bun run dev exec resume <id> "again" # continue a sessionCommands
obrew exec [resume <sessionId>] [--json] [--model <id>] [--effort low|medium|high]
[-c key=value ...] [--cwd <dir>] [--system-prompt <t> | --system-prompt-file <p>]
[--mcp-server name=<url> ...] [--tools Read,Grep,Glob|none]
[--image <path> ...] [--vision] ("<prompt>" | --input-format json)
obrew auth status [--json]
obrew login [--model <repo[:file]>] [--mmproj | --no-mmproj] [--json]
obrew models list|pull <repo>[:file] [--mmproj]|rm <id>|use <id>
obrew engine install [--variant cuda|cpu|vulkan|metal] | status | stop
obrew sessions list|show <id>|rm <id>Every run-time knob is also a -c key=value pair (thinking, maxtokens, temperature, ctxsize, ngpulayers, toolmode, ...), so exec and exec resume accept the identical flag set.
A host driving exec should pass the turn's text on stdin rather than in argv: with --input-format json, exec reads one JSON object, {"prompt": "...", "systemPrompt": "...", "outputSchema": {...}} (systemPrompt and outputSchema optional), and then EOF. That keeps a long prompt, or a large schema, clear of the ~32 KB Windows allows for a whole command line, and nothing has to be written to a temp file.
Which model runs
exec with no --model runs the default: the one chosen with obrew models use <id, else the built-in Gemma 4 E2B when the machine has it, else whatever else it has — so a machine set up with obrew models pull alone still runs. A plain pull never takes the default away from a model that already holds it, and removing the default falls back to a model that is left rather than to one that was never downloaded. obrew models list marks the default with , and obrew auth status reports it and whether it is installed.
obrew login installs that same default, so a choice survives the next login instead of being replaced by the built-in model; login --model <repo[:file] names one and makes it the default.
Tools are constrained, always
The model never emits free-form tool JSON. With a chat template that knows about tools (Qwen 2.5/3, Llama 3.x, Hermes, Mistral, DeepSeek), obrew sends the tool schemas and llama.cpp decodes the call under a grammar built from them. With any other template, or -c toolmode=universal, choosing a tool and filling its arguments are two separate requests, each decoded under a JSON schema. Either way the arguments are validated against the tool's schema before it runs, and an invalid call is repaired once under that schema. A failing tool is reported back to the model as a result; it never ends the run.
--output-schema '{...}' (or @file.json, or outputSchema on stdin) decodes the final answer under a schema and puts the parsed value on turn.completed.output; --grammar @file.gbnf does the same with GBNF. With tools available the model may use them first and only the answer is constrained; with --tools none the constrained request is the whole turn.
MCP servers
--mcp-server name=http://127.0.0.1:1234/mcp dials a streamable-HTTP MCP server for this run only; --mcp-server name=stdio:<command … launches one as a child process. Its tools appear to the model as mcp<name<tool, get the same schema validation as built-ins, and a result the server marks isError is fed back to the model rather than ending the run.