A voice agent with every part on one node. Grant a sandbox infer:run and vision:run, and the code inside it can call the model, the scraper and the speech model with no key: the sandbox gets OPENAI_BASE_URL, a placeholder OPENAI_API_KEY and B3OS_MODEL, and B3OS answers at http://127.0.0.1:7700. The agent (agent.mjs in the recipe) asks the model which tasks on the demo app's launch board are in progress and which of them are engineering work. The model reads the board with read_page (a vision/scrape of the page as Markdown), then calls answer with one or two sentences meant to be heard, followed by the task titles. The model's calls stream, and the spoken part comes first, so the agent does not wait for the whole answer: as soon as the first sentence is complete it goes to the speech model as a stream, while the model is still writing the rest, and the rest is spoken after it.

js
const r = await ask((call) => { // call.arguments grows as the model writes: {"spoken": "Four tasks are in progress. Two of them ... const so = spokenSoFar(call.arguments); const sentence = so && firstSentence(so); if (sentence && !firstSpeech) firstSpeech = speak(sentence); // POST /v1/infer/audio/speech, stream_format "audio"});

The agent's own code then reads the board's in-progress column from the same Markdown, so the titles the model gave are checked against the page and not against the model, and what is said aloud has to name each of those tasks. question_to_first_audio_ms below runs from the first model call to the first audio bytes, measured inside the sandbox; speech_started_ms is when the first sentence went to the speech model and answer_done_ms when the model's answer was complete.

Check

Verified 2026-11-02: this recipe's last run passed against https://beta-api.b3os.org, label live-recorded (recorded from production), from commit 6f393285. The recipe harness runs it again, and a failed run takes this page out of the cookbook.

Run it

Each tab creates the sandbox with the grant, copies agent.mjs in, runs it, downloads the answer as answer.mp3 and deletes the sandbox.

bash
SBX=$(curl -s https://beta-api.b3os.org/v1/sandboxes \ -H "Authorization: Bearer $B3OS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"template": "base", "timeout_s": 300, "api": {"scopes": ["infer:run", "vision:run"], "spend_cap_usd": 1}}' \ | jq -r .id)curl -s -X PUT "https://beta-api.b3os.org/v1/sandboxes/$SBX/files?path=/tmp/agent.mjs" \ -H "Authorization: Bearer $B3OS_API_KEY" \ -H "Content-Type: application/octet-stream" \ --data-binary @agent.mjscurl -s "https://beta-api.b3os.org/v1/sandboxes/$SBX/exec" \ -H "Authorization: Bearer $B3OS_API_KEY" \ -H "Content-Type: application/json" \ -d '{"cmd": "node /tmp/agent.mjs", "timeout_ms": 60000}' | jq -r .stdoutcurl -s "https://beta-api.b3os.org/v1/sandboxes/$SBX/files?path=/tmp/answer.mp3" \ -H "Authorization: Bearer $B3OS_API_KEY" -o answer.mp3curl -s -X DELETE "https://beta-api.b3os.org/v1/sandboxes/$SBX" -H "Authorization: Bearer $B3OS_API_KEY"

model_ms, scrape_ms and the speech figures below are measured inside the sandbox, on the same machine as the model, the scraper and the speech model. speech_ttfa_server_ms is the node's own ttfa from the speech response's Server-Timing. The demo app is a fictional workspace with made-up tasks.

Owner: Docs. Products: Docs, Sandboxes, Vision, Infer.

Output of the last green run

bash
$ node run.mjs# exit 0

The outputs this recipe publishes (its recipe.json publish.outputs), with workspace identifiers redacted. The run's stdout, stderr and other outputs are not published.

Captured 2026-11-02 from commit 6f393285 , label live-recorded (recorded from production). Case recipes/sandbox-voice-agent .

Ask a question... ⌘I