Packaged fn serve/daemon/dashboard hosts previously failed with a misleading missing-API-key error for grok-cli agents even though the operator was already logged in via the Grok CLI. This fixes routing so those hosts eagerly ensure the bundled Grok Runtime plugin is installed/loaded before session creation, and no longer silently falls back to the key-requiring direct endpoint when no key is visible. - Eagerly ensure the bundled fusion-plugin-grok-runtime in serve, daemon, and dashboard commands before loadAllPlugins() so runtime id "grok" is available on fresh installs without manual plugin-settings setup. - agent-session-helpers.ts: deriveGrokRuntimeHintForNoVisibleKey now throws an actionable error (naming both remediations: install/enable the Grok CLI runtime plugin, or set GROK_API_KEY) instead of silently falling through to the key-requiring pi/openai-completions path when the runtime can't be loaded. - Update docs/grok-cli-contract.md to document the FN-7761 packaged-host wiring and new no-silent-fallback behavior. - Add regression tests for the packaged bootstrap behavior and bundled-plugin install path. - Add changeset for @runfusion/fusion (patch, category: fix). Files changed: .changeset/fn-7761-grok-cli-packaged-routing.md | 7 +++++ docs/grok-cli-contract.md | 19 +++++++++---- .../__tests__/grok-runtime-bootstrap.test.ts | 31 ++++++++++++++++++++++ packages/cli/src/commands/daemon.ts | 17 +++++++++++- packages/cli/src/commands/dashboard.ts | 20 +++++++++++++- packages/cli/src/commands/serve.ts | 19 +++++++++++-- .../__tests__/bundled-plugin-install.test.ts | 17 ++++++++++++ .../src/__tests__/grok-runtime-routing.test.ts | 17 ++++++------ packages/engine/src/agent-session-helpers.ts | 15 +++++++++-- 9 files changed, 142 insertions(+), 20 deletions(-) Fusion-Task-Id: FN-7761 Fusion-Task-Lineage: 3be5f054-965c-4e8a-ad91-6e61d4dc4a42 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
17 KiB
Grok CLI Contract (FN-7722)
Date: 2026-07-09
Research method
fn_web_fetchagainst the canonical upstream repository (https://github.com/superagent-ai/grok-cli), specifically:README.md(headless-mode overview, feature summary).src/index.ts(commander CLI argument parsing — the exact flag spellings and headless dispatch).src/headless/output.ts(the actual NDJSON event emitter — the authoritative schema source, not just docs prose).src/headless/output.test.ts(fixture-level confirmation of the emitted JSONL shapes, used as ground truth for this plugin's own fixture tests).
- No live
grokbinary was invoked; no field name or flag spelling in this document is guessed — every claim below traces to one of the four files above. Raw captured research (queries + verbatim schema) is preserved as this task'sresearchtask document (fn_task_document_readkeyresearchon FN-7722).
Confirmed non-interactive invocation
grok --prompt "<text>" --format json
# short flags:
grok -p "<text>" --format json
-p, --prompt <prompt>— run a single prompt headlessly, then exit.--format <format>— headless output format,text(default) orjson; invalid values are rejected by commander'sInvalidArgumentError(parseHeadlessOutputFormat/isHeadlessOutputFormatinsrc/index.ts).- Useful companion flags confirmed in the same
program.option(...)chain:-d, --directory <dir>(cwd),-m, --model <model>,-s, --session <id>(resume a saved session, orlatest),-k, --api-key <key>(inline key). --format jsonoutput is newline-delimited JSON (NDJSON/JSONL) — one JSON object per line — not a single JSON document. This is directly confirmed bycreateHeadlessJsonlEmitter()'sjsonLine()helper insrc/headless/output.ts, which appends\nafter eachJSON.stringify.
Verified NDJSON event schema (verbatim)
Source: HeadlessJsonEvent union type in src/headless/output.ts.
type HeadlessJsonEvent =
| { type: "step_start"; sessionID?: string; stepNumber: number; timestamp: number }
| { type: "text"; sessionID?: string; stepNumber: number; text: string; timestamp: number }
| {
type: "tool_use";
sessionID?: string;
stepNumber: number;
timestamp: number;
toolCall: ToolCall;
toolResult: ToolResult;
timing?: { startedAt?: number; finishedAt?: number; durationMs?: number };
}
| {
type: "step_finish";
sessionID?: string;
stepNumber: number;
timestamp: number;
finishReason: string;
usage: { inputTokens?: number; outputTokens?: number; totalTokens?: number; costUsdTicks?: number };
}
| { type: "error"; sessionID?: string; message: string; timestamp: number };
Notes:
sessionIDappears on every event type when a session id is available (agent.getSessionId()); it is simply absent from the JSON object otherwise (notnull).textevents are per-step, buffered assistant content — onetextevent per step carrying the accumulated text for that step, flushed either right before a tool-triggeringstep_finishor inline with a tool-lessstep_finish.- No
thinking/reasoningNDJSON event exists. The underlyingStreamChunkunion used internally does carry a"reasoning"chunk type, butcreateHeadlessJsonlEmitter().consumeChunk()explicitly no-ops on it (case "reasoning": break;insrc/headless/output.ts) — reasoning content is never surfaced through--format json. This is a confirmed absence, notupstream-pending-verification: the Grok streaming adapter therefore drivesonTextonly; there is noonThinkingsignal to bridge for this CLI path today. - There is no explicit terminal
done/resultevent type. A prompt run can contain multiplestep_start/step_finishpairs (multi-round tool use); the authoritative "the run is over" signal is the headless process's stdout stream ending (readlineclose) / subprocess exit, mirroring how the Droid CLI adapter treats subprocesscloseas terminal.errorevents ({ type: "error", message, timestamp }) can also appear inline without necessarily ending the process. - A fatal, pre-JSON failure (e.g. missing API key) is not a JSON line at
all:
src/index.ts'srequireApiKey()writes a plainconsole.error(...)line to stderr and callsprocess.exit(1)before any NDJSON is emitted. Consumers must therefore also treat a non-zero exit with no JSON output as a distinct failure mode from a well-formederrorevent.
Auth / readiness
- The
grokCLI owns authentication end-to-end for CLI-routed execution.runHeadless()insrc/index.tsis only reached viarequireApiKey(config.apiKey), which resolves the key from (in order viaresolveConfig/getApiKey()):-k/--api-keyflag,GROK_API_KEYenv var, project.env, or~/.grok/user-settings.json'sapiKeyfield. If none resolve, the CLI itself exits 1 with an actionable error — Fusion does not need to pass, see, or validate a key for this path to work, as long as the operator'sgrokinstall already has one configured by any of those methods. - Auth implication for this task: because CLI-routed model selections let
the
grokbinary own both auth and inference, the direct-endpointGROK_API_KEYFusion-visibility requirement established by FN-7711 (built-inxai/openai-completionsprovider) and FN-7714 (hydratingGROK_API_KEYfrom~/.grok/user-settings.jsonwhen the env var is unset) becomes unnecessary for CLI-routed selections specifically. It remains necessary and unchanged for the direct xAI OpenAI-compatible path, which stays the default (see "What stays unchanged" below). - This mirrors FN-7716's separate finding that Grok CLI readiness (probe/
auth-status surfacing) does not require Fusion to see a key either — that
surface (
probe.ts,register-auth-routes.ts,GrokCliProviderCard.tsx) is out of scope for this task and is not modified here.
Wiring (resolved — FN-7725, extended by FN-7753/FN-7758/FN-7761)
Decision: option (a) — formalize, document, and test the existing agent Runtime-mode picker path. Do NOT add a new settings toggle (option (b)). FN-7753 later closed the deferred no-key model-selection fallback without adding that rejected UI toggle: the session seam derives the same runtime hint automatically only when the direct endpoint cannot work because no Fusion-visible GROK_API_KEY resolves.
Explicit trigger: an agent's runtimeConfig.runtimeHint === "grok", set today
via the dashboard's agent Runtime Source → Runtime picker
(NewAgentDialog.tsx / AgentDetailView.tsx), which is populated from
GET /api/plugins/runtimes (already generic — surfaces every registered
plugin runtime, including the bundled Grok Runtime plugin's runtimeId: "grok", with no Grok-specific code required).
Automatic no-key fallback (FN-7753/FN-7758/FN-7761): when createResolvedAgentSession() sees
all of the following, it derives the same effective runtimeHint: "grok" before
calling resolveRuntime():
- no explicit runtime hint was supplied (explicit hints, including
"pi", always win); - the resolved primary/default provider is
grok-cli, or the configured fallback provider isgrok-cli; - Fusion cannot see a non-empty
GROK_API_KEYeither in the environment or in~/.grok/user-settings.json'sapiKeyfield; and - the bundled Grok Runtime plugin has registered runtime id
"grok".
FN-7758 also requires dashboard Chat/QuickChat and room responders to forward
the configured default provider/model into this same session seam when a send has
no explicit model and no bound agent runtime model. That keeps the no-key routing
invariant identical across executor, reviewer/validator/merger-adjacent, single
chat, QuickChat, and room responder surfaces instead of letting model-less chat
bypass the auto-derive by omitting defaultProvider.
If a Fusion-visible key exists, the direct xAI OpenAI-compatible endpoint remains
the default. If the Grok runtime is not registered or cannot be loaded in the
no-visible-key grok-cli case, Fusion no longer leaves the session on the
existing PI/direct path because that path produces the misleading missing-key
error. Instead it raises an actionable error that names both supported recovery
paths: install/enable the Grok CLI runtime plugin so the logged-in grok CLI
owns auth, or set GROK_API_KEY so the direct xAI endpoint can authenticate.
Exact seam: packages/engine/src/agent-session-helpers.ts's
extractRuntimeHint(runtimeConfig) reads that hint from the assigned agent's
runtimeConfig and threads it, as runtimeHint, into
packages/engine/src/runtime-resolution.ts's resolveRuntime() — which is
totally runtime-agnostic: when the hint matches a registered plugin
runtimeId, resolvePluginRuntime() calls that plugin's runtime.factory
(the Grok plugin's factory returns new GrokRuntimeAdapter(),
plugins/fusion-plugin-grok-runtime/src/index.ts) and the resolved adapter
becomes the session's runtime. This same generic chain already carries
"hermes" and "droid" runtime hints end-to-end (see
hermes-runtime-integration.test.ts, droid-runtime-e2e.test.ts) — Grok's
plugin registration alone was sufficient for the chain to reach it; no
engine or dashboard code changed for this task, because the generic
Runtime-mode picker → extractRuntimeHint → resolveRuntime →
resolvePluginRuntime → plugin factory chain was already correct and
exercised for other plugin runtimes. FN-7725 formalizes this as the decided
Grok wiring, adds packages/engine/src/__tests__/grok-runtime-routing.test.ts
proving the chain specifically resolves GrokRuntimeAdapter (id "grok")
and drives its onText streaming seam via a faked spawn (see
runtime-adapter.ts's injectable spawn option; no live grok binary), and
records the decision here plus in plugins/fusion-plugin-grok-runtime/README.md.
Why option (a), not (b): option (b) (an opt-in "prefer CLI runtime"
setting deriving runtimeHint: "grok" from a grok-cli/* model selection)
would add a new Settings field, defaulting/resolution logic, and a
SettingsModal UI toggle (desktop + mobile) — net-new surface area for a path
that, on inspection, was already fully wired generically by the existing
Runtime-mode picker. Per the Decision guidance's preference for "the smaller,
additive change," formalizing + testing + documenting the already-working
path is lower risk and closes the actual gap (an exercised path, not just
an implemented adapter) without adding new user-facing config surface.
Model plumbing (FN-7753/FN-7758/FN-7761): for the automatic no-key fallback, the selected
grok-cli/* model id is preserved through AgentRuntimeOptions.defaultModelId
(or promoted from fallbackModelId when the fallback provider is the grok-cli
selection), normalized by stripping a leading grok-cli/ (or grok/) prefix,
and passed to the CLI as grok --model <id> alongside --prompt and
--format json. Runtime-mode remains model-agnostic when chosen explicitly from
the dashboard; that no-model path still uses the adapter's historical
"grok/default" session fallback and omits --model.
Why the direct xAI endpoint stays default: a grok-cli/* model selection
continues to route through the direct xAI OpenAI-compatible endpoint
(FN-7711/FN-7714, packages/core/src/grok-provider.ts, packages/engine/src/pi.ts)
whenever Fusion can see a key. FN-7753 changes only the failing no-visible-key
case, where the direct path would otherwise hard-fail even though the installed
CLI may be authenticated by a source Fusion cannot inspect (project .env,
grok -k, OAuth/login token store, sandbox secrets, etc.).
Decision
Route Grok execution through the CLI: YES, as a scoped, additive
GrokRuntimeAdapter implementation.
Rationale:
- The non-interactive contract is fully pinned to primary source code
(
src/index.tsCLI parsing +src/headless/output.tsemitter +src/headless/output.test.tsfixtures), not just README prose — this clears the External Integration Evidence bar and the "testable from fixture lines without a live binary" bar from the task mission — the parser can be fixture-tested exactly like the Droid plugin'sstream-parser.ts, with no live-binary dependency in tests. - The event schema is simple (
step_start/text/tool_use/step_finish/error) and text-only for this scoped adapter (nothinkingevent exists to bridge), so the implementation stays narrow: a resilient NDJSON line parser plus anonTextbridge, deliberately leaving tool-call/break-early bridging as a documented follow-up (the Droid adapter's much largerprovider.tsis the effort ceiling, not the target shape). - It is fully reversible: the adapter is reachable via either an explicit
runtimeHint === "grok"or FN-7753's narrow no-visible-keygrok-clifallback. The direct endpoint remains the key-visible default.
What stays unchanged
- The direct xAI OpenAI-compatible streaming path (base URL
https://api.x.ai/v1, api typeopenai-completions,GROK_API_KEYsourced per FN-7711/FN-7714) remains the default when a Fusion-visible key exists. FN-7753 adds a read-only key-visibility check inpackages/core/src/grok-provider.tsand derives CLI routing only when no such key is visible and the Grok runtime is registered. - FN-7716's probe/auth-readiness surface (
probe.ts,register-auth-routes.ts,GrokCliProviderCard.tsx) is untouched by this task. - End-to-end routing was out of scope for FN-7722 and is resolved by FN-7725 (see "Wiring" above): decision option (a), formalizing the existing agent Runtime-mode picker path. No settings toggle was added.
Follow-ups filed from this task
See the task's fn_task_create calls (linked from FN-7722) for:
End-to-end routing wiring— closed by FN-7725 (see "Wiring" above): the agent Runtime-mode picker path was formalized, documented, and covered bypackages/engine/src/__tests__/grok-runtime-routing.test.ts.Full tool-call bridging for— closed by FN-7724:tool_useNDJSON eventsGrokRuntimeAdapternow bridgestool_useintoonToolStart/onToolEnd(no Grok→pi tool-name mapping was added — the verified schema does not pin grok-cli's tool-name vocabulary, so names/args pass through unchanged). Break-early onstep_finishwas deliberately NOT adopted: this doc's own "Verified NDJSON event schema" notes above establishstep_finishis a per-step boundary (a run can contain multiplestep_start/step_finishpairs), not the run terminal — the adapter's terminal signal remains subprocessclose/error, unchanged from FN-7722. Seeplugins/fusion-plugin-grok-runtime/README.md's "Tool execution bridging (FN-7724)" section.Preserving a specific— closed by FN-7753 for the automatic no-visible-key fallback:grok-cli/*model selection when routing through the CLI runtimecreateResolvedAgentSession()derives runtime hint"grok"only when no explicit hint is set, provider isgrok-cli, no Fusion-visibleGROK_API_KEY/user-settingsapiKeyresolves, and runtime id"grok"is registered; the selected model is passed to the CLI via--model <id>. Explicit Runtime-mode remains model-agnostic by design.