diff --git a/docs/cowork.md b/docs/cowork.md
new file mode 100644
index 0000000..1a8288d
--- /dev/null
+++ b/docs/cowork.md
@@ -0,0 +1,85 @@
+# Cowork — a desktop app for InferRoute, powered by goose
+
+`ir` routes **Claude Code** through InferRoute. **Cowork** is the equivalent for
+everyday, non-terminal work: research, writing, working with files — a
+point-and-click app, no terminal needed.
+
+It's powered by [**goose**](https://github.com/block/goose) (Block / Linux
+Foundation, Apache-2.0), an open-source agent with both a **desktop app** and a
+**CLI**. We don't fork it — `ir cowork` just wires goose to InferRoute and
+launches it. Because the wiring is config-only and re-asserted on every launch,
+goose updates can't drift it out of sync.
+
+## Fastest path
+
+```bash
+ir cowork
+```
+
+This will:
+1. install the goose CLI if it isn't already present (or you can grab the
+ desktop app from );
+2. point goose at InferRoute — provider `anthropic`, your saved key, and routing
+ through your on-device recorder daemon if it's running (`ir add recording`),
+ otherwise straight to the cloud;
+3. launch the **desktop app** if it's installed, otherwise the goose CLI.
+
+You'll also be offered Cowork at the end of `ir setup`.
+
+| command | what it does |
+|---|---|
+| `ir cowork` | wire goose to InferRoute and launch it |
+| `ir cowork --cli` | force the goose CLI even if the desktop is installed |
+| `ir cowork --configure-only` | wire it up, don't launch |
+| `ir cowork --model NAME` | pin a model (short alias like `kimi`/`glm`, or a canonical id) |
+
+## In the app
+
+When the desktop app opens, pick a model from the list (populated from InferRoute's
+`/v1/models`) and start a session. The same `inf_…` key you use with `ir` works
+here — Cowork reads it from goose's local secret store, which `ir cowork` writes.
+
+## What gets configured (and why it's safe)
+
+`ir cowork` owns a small, stable set of keys and **merges** them into goose's
+files without touching your other goose settings:
+
+- `~/.config/goose/config.yaml` — `GOOSE_PROVIDER: anthropic`,
+ `GOOSE_MODEL`, `ANTHROPIC_HOST` (your recorder daemon or `api.inferroute.ai`)
+- `~/.config/goose/secrets.yaml` (mode 600) — `ANTHROPIC_API_KEY` and a
+ `x-inferroute-client: cowork` tag so the dashboard can attribute Cowork traffic
+- `~/.config/environment.d/99-inferroute-goose.conf` (Linux) —
+ `GOOSE_DISABLE_KEYRING=true`, so goose uses its file secret store on machines
+ without a working OS keyring
+
+These are re-written every time you run `ir cowork`, so they self-heal across
+goose updates.
+
+## Wiring it by hand
+
+If you'd rather not use `ir cowork`, point any goose install at InferRoute:
+
+```yaml
+# ~/.config/goose/config.yaml
+GOOSE_PROVIDER: anthropic
+GOOSE_MODEL: moonshotai/Kimi-K2.6-TEE # any model from /v1/models
+ANTHROPIC_HOST: https://api.inferroute.ai # or http://localhost:5005 to record locally
+```
+
+```yaml
+# ~/.config/goose/secrets.yaml (chmod 600)
+ANTHROPIC_API_KEY: inf_your_key_here
+```
+
+On Linux, also set `GOOSE_DISABLE_KEYRING=true` (e.g. in
+`~/.config/environment.d/`) if your machine has no working keyring.
+
+## Notes
+
+- **Updates:** the goose CLI is a pinned binary — update it on your schedule with
+ `goose update`. The desktop app auto-updates; `ir cowork` re-asserts the config
+ each launch, so that's handled.
+- **Cost display:** goose's in-app cost readout depends on
+ [block/goose#9719](https://github.com/block/goose/pull/9719); until it merges,
+ cost may show as "unavailable" even though routing and billing work normally —
+ see your real spend with `ir status` or on the dashboard.
diff --git a/inferroute_cli/cowork.py b/inferroute_cli/cowork.py
new file mode 100644
index 0000000..e34375c
--- /dev/null
+++ b/inferroute_cli/cowork.py
@@ -0,0 +1,259 @@
+"""`ir cowork` — InferRoute's everyday-work surface, powered by goose.
+
+goose (https://github.com/block/goose, Apache-2.0) is an open-source agent with
+a desktop app *and* a CLI. `ir cowork` wires it to InferRoute and launches it:
+
+ • provider → anthropic (goose speaks the Anthropic Messages API, like Claude Code)
+ • routing → the on-device recorder daemon when it's running, else the cloud
+ • key → your saved inferroute key
+ • tag → x-inferroute-client: cowork (so the dashboard can attribute it)
+
+Why this is cheap and stable:
+ • Config-only — we do NOT fork goose. We own a small set of goose config/secret
+ keys and re-assert them on every launch, so a goose update can't drift us out
+ of sync (and goose's CLI is a pinned binary you update with `goose update`).
+ • goose reads these from files, so the desktop app (launched from the menu) and
+ the CLI both pick them up.
+
+The desktop app is the point-and-click way to use InferRoute for everyday work —
+research, writing, files — no terminal needed. The CLI is the same engine in the
+terminal.
+"""
+from __future__ import annotations
+
+import os
+import shutil
+import stat
+import subprocess
+import sys
+from pathlib import Path
+
+from . import config
+
+GOOSE_DIR = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "goose"
+CONFIG_FILE = GOOSE_DIR / "config.yaml"
+SECRETS_FILE = GOOSE_DIR / "secrets.yaml"
+CLIENT_TAG = "cowork"
+GOOSE_INSTALL_URL = "https://github.com/block/goose/releases/download/stable/download_cli.sh"
+GOOSE_DESKTOP_DOWNLOAD = "https://block.github.io/goose/"
+
+
+# ── small YAML helpers (PyYAML) ──────────────────────────────────────────────
+def _load_yaml(path: Path) -> dict:
+ if not path.exists():
+ return {}
+ try:
+ import yaml
+
+ data = yaml.safe_load(path.read_text()) or {}
+ return data if isinstance(data, dict) else {}
+ except Exception:
+ # A malformed/locked file shouldn't crash the launcher — start fresh,
+ # but don't clobber: only merge our keys in _write_merged below.
+ return {}
+
+
+def _write_merged(path: Path, updates: dict, *, secret: bool) -> None:
+ """Merge ``updates`` into the YAML at ``path``, preserving the user's other
+ keys. Secret files are written 0600."""
+ import yaml
+
+ data = _load_yaml(path)
+ data.update(updates)
+ path.parent.mkdir(parents=True, exist_ok=True)
+ if secret:
+ os.chmod(path.parent, stat.S_IRWXU)
+ path.write_text(yaml.safe_dump(data, default_flow_style=False, sort_keys=False))
+ if secret:
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
+
+
+# ── resolution ───────────────────────────────────────────────────────────────
+def _default_model() -> str:
+ """A balanced model InferRoute serves. Mirrors the `ir` default agent model."""
+ try:
+ from . import models
+
+ alias = models.get("kimi")
+ if alias is not None:
+ return alias.model_id
+ except Exception:
+ pass
+ return "moonshotai/Kimi-K2.6-TEE"
+
+
+def _anthropic_host(creds: config.Credentials) -> str:
+ """Route through the on-device recorder daemon when it's up (records + tags
+ the session, then forwards to the cloud), else talk to the cloud directly."""
+ try:
+ from . import launch
+
+ return launch._recording_daemon_url() or creds.api_url
+ except Exception:
+ return creds.api_url
+
+
+def _goose_cli() -> str | None:
+ return shutil.which("goose") or next(
+ (str(p) for p in [Path.home() / ".local" / "bin" / "goose"] if p.exists()), None
+ )
+
+
+def _goose_desktop() -> str | None:
+ """Path to the goose Desktop binary for this platform, or None."""
+ home = Path.home()
+ candidates: list[Path] = []
+ if sys.platform == "darwin":
+ candidates = [Path("/Applications/Goose.app/Contents/MacOS/Goose")]
+ elif sys.platform.startswith("win"):
+ la = os.environ.get("LOCALAPPDATA", "")
+ if la:
+ candidates = [Path(la) / "Programs" / "goose" / "Goose.exe"]
+ else: # linux
+ candidates = [
+ home / ".local" / "opt" / "goose" / "Goose",
+ Path("/usr/lib/goose/Goose"),
+ ]
+ w = shutil.which("Goose") or shutil.which("goose-desktop")
+ if w:
+ candidates.insert(0, Path(w))
+ return next((str(p) for p in candidates if p.exists()), None)
+
+
+# ── configure (idempotent, re-asserted every launch) ─────────────────────────
+def configure(creds: config.Credentials, model: str | None = None) -> str:
+ """Write InferRoute's goose config + secrets. Returns the routing host."""
+ model = model or _default_model()
+ host = _anthropic_host(creds)
+
+ _write_merged(
+ CONFIG_FILE,
+ {"GOOSE_PROVIDER": "anthropic", "GOOSE_MODEL": model, "ANTHROPIC_HOST": host},
+ secret=False,
+ )
+ _write_merged(
+ SECRETS_FILE,
+ {
+ "ANTHROPIC_API_KEY": creds.api_key,
+ "ANTHROPIC_CUSTOM_HEADERS": {"x-inferroute-client": CLIENT_TAG},
+ },
+ secret=True,
+ )
+ # goose stores secrets in the OS keyring by default; mgld-style headless boxes
+ # (and many Linux desktops) have no working keyring, so point goose at its file
+ # secret store. Set it for the GUI session too (Linux), so a menu-launched
+ # desktop also reads secrets.yaml.
+ if not sys.platform.startswith("win") and sys.platform != "darwin":
+ envd = Path(os.environ.get("XDG_CONFIG_HOME") or (Path.home() / ".config")) / "environment.d"
+ try:
+ envd.mkdir(parents=True, exist_ok=True)
+ (envd / "99-inferroute-goose.conf").write_text("GOOSE_DISABLE_KEYRING=true\n")
+ except Exception:
+ pass
+ return host
+
+
+def _launch_env() -> dict:
+ env = dict(os.environ)
+ env["GOOSE_DISABLE_KEYRING"] = "true"
+ return env
+
+
+def _install_goose_cli(assume_yes: bool) -> bool:
+ if _goose_cli():
+ return True
+ print("\n goose isn't installed yet.")
+ if not assume_yes:
+ resp = input(" Install the goose CLI now (open-source, ~from block/goose)? [Y/n] ").strip().lower()
+ if resp in ("n", "no"):
+ return False
+ print(" Installing goose…")
+ try:
+ rc = subprocess.call(
+ f"curl -fsSL {GOOSE_INSTALL_URL} | CONFIGURE=false bash",
+ shell=True,
+ )
+ except Exception as e: # pragma: no cover
+ print(f" ✗ install failed: {e}")
+ return False
+ if rc != 0 or not _goose_cli():
+ print(" ✗ goose install didn't complete. Install it manually: https://block.github.io/goose/")
+ return False
+ return True
+
+
+# ── commands ─────────────────────────────────────────────────────────────────
+def setup_cowork() -> int:
+ """Called from `ir setup`: install (if the user wants) + configure, no launch."""
+ creds = config.load()
+ if not creds.is_valid:
+ print(" Skipping cowork — log in first (`ir login`).")
+ return 0
+ _install_goose_cli(assume_yes=False)
+ host = configure(creds)
+ routed = "the on-device recorder" if "localhost" in host else "InferRoute"
+ print(f"\n ✓ Cowork is wired to {routed}.")
+ if _goose_desktop():
+ print(" Launch the desktop app anytime, or run: ir cowork")
+ else:
+ print(f" Get the desktop app: {GOOSE_DESKTOP_DOWNLOAD}")
+ print(" Or use it in the terminal now: ir cowork")
+ return 0
+
+
+def cmd_cowork(rest: list[str]) -> int:
+ """`ir cowork [--cli] [--configure-only] [--model NAME] [-- ]`."""
+ import argparse
+
+ ap = argparse.ArgumentParser(prog="ir cowork", add_help=True)
+ ap.add_argument("--cli", action="store_true", help="run the goose CLI even if the desktop is installed")
+ ap.add_argument("--configure-only", action="store_true", help="wire goose to InferRoute, don't launch")
+ ap.add_argument("--model", default=None, help="model to pin (default: kimi)")
+ ns, passthrough = ap.parse_known_args(rest)
+ if passthrough and passthrough[0] == "--":
+ passthrough = passthrough[1:]
+
+ creds = config.load()
+ if not creds.is_valid:
+ sys.stderr.write("\n Not logged in. Run `ir login` (or `ir setup`) first.\n\n")
+ return 2
+
+ model = None
+ if ns.model:
+ try:
+ from . import models
+
+ alias = models.get(ns.model)
+ model = alias.model_id if alias is not None else ns.model
+ except Exception:
+ model = ns.model
+
+ host = configure(creds, model=model)
+
+ if ns.configure_only:
+ print(f" ✓ goose wired to InferRoute ({host}).")
+ return 0
+
+ # Launch: prefer the desktop (the point-and-click experience) unless --cli.
+ desktop = None if ns.cli else _goose_desktop()
+ env = _launch_env()
+ if desktop:
+ print(f" Launching goose desktop (InferRoute · {host})…")
+ args = [desktop]
+ if not (sys.platform == "darwin" or sys.platform.startswith("win")):
+ args.append("--no-sandbox") # chrome-sandbox isn't setuid in a user-local install
+ try:
+ subprocess.Popen(args, env=env, start_new_session=True,
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ return 0
+ except Exception as e:
+ print(f" ✗ couldn't launch the desktop ({e}); falling back to the CLI.")
+
+ goose = _goose_cli()
+ if not goose:
+ if not _install_goose_cli(assume_yes=False):
+ return 1
+ goose = _goose_cli()
+ print(f" Launching goose (InferRoute · {host})…")
+ os.execvpe(goose, [goose, *passthrough], env)
+ return 0 # never reached
diff --git a/inferroute_cli/help.py b/inferroute_cli/help.py
index 7109dad..246b387 100644
--- a/inferroute_cli/help.py
+++ b/inferroute_cli/help.py
@@ -28,6 +28,13 @@ def run(args=None) -> int:
lines.append(" ir choose Same as bare `ir` — interactive picker, then launch")
lines.append(" ir anthropic Escape hatch — plain claude, no inferroute env touched")
lines.append("")
+ lines.append("Cowork — a desktop app for everyday work, powered by goose")
+ lines.append(" ir cowork Wire goose to inferroute and launch it (the desktop app if")
+ lines.append(" installed, else the CLI). Point-and-click; no terminal needed.")
+ lines.append(" ir cowork --cli Force the goose CLI instead of the desktop")
+ lines.append(" ir cowork --configure-only")
+ lines.append(" Wire goose to inferroute without launching")
+ lines.append("")
lines.append("Deferred / economy lane")
lines.append(" ir gate Poll the economy gate. Exit 0 = cheap window (run a cycle),")
lines.append(" 1 = skip. Use in a loop: `if ir gate; then run; else sleep 30; fi`")
diff --git a/inferroute_cli/main.py b/inferroute_cli/main.py
index cc4ec8f..c041308 100644
--- a/inferroute_cli/main.py
+++ b/inferroute_cli/main.py
@@ -156,6 +156,12 @@ def main(argv: list[str] | None = None) -> int:
from . import data as data_mod
return data_mod.cmd_data(rest)
+ if cmd == "cowork":
+ # `ir cowork` — wire goose (open-source agent desktop/CLI) to InferRoute
+ # and launch it. The point-and-click surface for everyday work.
+ from . import cowork as cowork_mod
+ return cowork_mod.cmd_cowork(rest)
+
if cmd == "anthropic":
# Escape hatch — no env, no key check, no inferroute touch.
launch.launch_native_anthropic(extra_args=rest)
diff --git a/inferroute_cli/setup.py b/inferroute_cli/setup.py
index 39e84dd..0929d81 100644
--- a/inferroute_cli/setup.py
+++ b/inferroute_cli/setup.py
@@ -43,10 +43,29 @@ def run(rest=None) -> int:
# setup on the result — a failure here shouldn't block a logged-in user.
add_mod.cmd_add(["recording", "--level", "full", "--yes"])
+ # ── Step 3: cowork (optional) ─────────────────────────────────────
+ # A desktop app for everyday (non-terminal) work, powered by the open-source
+ # agent goose. Opt-in — most CLI users won't want it, so we ask rather than
+ # default it on.
+ print("\n [3/3] Cowork — a desktop app for InferRoute, powered by goose (optional).")
+ print(" Point-and-click way to use InferRoute for everyday work — research,")
+ print(" writing, files — no terminal needed. Open-source (github.com/block/goose).")
+ try:
+ resp = input(" Set it up now? [y/N] ").strip().lower()
+ except (EOFError, KeyboardInterrupt):
+ resp = ""
+ if resp in ("y", "yes"):
+ from . import cowork as cowork_mod
+
+ cowork_mod.setup_cowork()
+ else:
+ print(" Skipped. Add it later with `ir cowork`.")
+
# ── Done ──────────────────────────────────────────────────────────
print()
print(" ✓ You're all set. Start any time with:")
print(" ir # pick a model, then launch")
print(" ir --model minimax # or pin one directly")
+ print(" ir cowork # the desktop app, powered by goose")
print()
return 0
diff --git a/pyproject.toml b/pyproject.toml
index 755eb21..e00a108 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -22,6 +22,7 @@ classifiers = [
dependencies = [
"textual>=0.78,<2.0",
"httpx>=0.27,<1.0",
+ "pyyaml>=6.0", # `ir cowork` reads/merges goose's config.yaml + secrets.yaml
]
# Optional on-device recorder. Adds a local HTTP daemon that intercepts CC