---
title: Run a tool call and stream its confirmation locally
---

# Run a tool call and stream its confirmation locally

Use OpenAI's unchanged Node SDK to receive a scripted function call. Your application validates
the arguments, creates an issue with the unchanged GitHub SDK, returns that issue as a tool
result, and streams a scripted confirmation. Read the stored conversation and issue, then stop
and resume the same World without creating another issue.

This example needs Node 22.6 or newer, npm and registry access during installation. It needs no
OpenAI or GitHub account, real API key or platform login. The two SDKs use their normal API
destinations; the World supplies throwaway credentials and routes the selected vendors to
their local twins. The issue belongs to the synthetic GitHub account. The model's tool call
and confirmation are authored scenario responses, not a judgment by a live model.

Start in an empty directory. Save files as you reach each section: initialize the World before
saving the authored handler, because `init` creates the initial empty handler file.

## Install and select the two twins

Save these first three files, then install and initialize the World.

```json file=package.json
{
  "name": "local-openai-tools-example",
  "private": true,
  "type": "module",
  "dependencies": {
    "openai": "4.104.0",
    "@octokit/rest": "22.0.1"
  },
  "devDependencies": {
    "@volter/world": "3.0.158",
    "@volter/world-core": "3.0.148",
    "@volter/world-runtime": "3.0.156",
    "@volter/world-console": "3.0.149",
    "@volter/twin-openai": "3.0.3",
    "@volter/twin-github": "3.0.9"
  }
}
```

```text file=.env.example
OPENAI_API_KEY=
GITHUB_TOKEN=
```

```text file=.gitignore
node_modules/
outcome.json
outcome.json.tmp
```

```bash
npm install
./node_modules/.bin/volter world init --name local-openai-tools-example --twins openai,github --source openai=@volter/twin-openai --source github=@volter/twin-github
```

Review `.volter/world.json` before starting: OpenAI is selected for the chat requests and
GitHub for the stored issue. The services should name `@volter/twin-openai` version `3.0.3`
and `@volter/twin-github` version `3.0.9`. Keep just these two vendors. Commit the generated
config and npm lockfile to preserve the selection. [Choose a twin](../../docs/guides/choose-a-twin.md)
explains how to inspect an exact release's supported operations and exercised coverage.

## Author the tool call and its confirmation

After `init` has finished, save the following handler file, replacing the empty one it created.
Saving it before `init` would let initialization overwrite the authored responses.

The first matching handler wins. Put the tool-result handler first: the later request still
contains the original user message and tool definition. The initial handler proposes a call;
it does not create the issue. The application performs that stored mutation through Octokit.

```json file=.volter/handlers/openai.json
{
  "handlers": [
    {
      "id": "issue-confirmation",
      "on": {
        "toolResultFor": "create_issue",
        "anyTextIncludes": "\"created\":true"
      },
      "respond": {
        "text": "Scripted confirmation: the application returned a stored GitHub issue. Its number and URL are in outcome.json."
      }
    },
    {
      "id": "request-issue",
      "on": {
        "userTextIncludes": "file the duplicate payment receipt",
        "hasTool": "create_issue"
      },
      "respond": {
        "toolCalls": [
          {
            "id": "call_create_issue",
            "name": "create_issue",
            "arguments": {
              "title": "P1: Duplicate payment receipt",
              "body": "A retried webhook creates duplicate receipts. Preserve audit history and deduplicate by provider event ID."
            }
          }
        ]
      }
    }
  ]
}
```

Select the saved scenario explicitly in the generated config. This helper preserves its
other settings and refuses a different vendor selection.

```js file=configure-scenario.mjs
import { readFileSync, writeFileSync } from 'node:fs';

const path = '.volter/world.json';
const config = JSON.parse(readFileSync(path, 'utf8'));
const ids = config.services.map((service) => service.id).sort();
if (ids.join(',') !== 'github,openai') {
  throw new Error('Expected only the deliberately selected github and openai services');
}
const service = config.services.find((entry) => entry.id === 'openai');
if (!service?.execution?.colocate) {
  throw new Error('Expected the generated colocated OpenAI service');
}
service.execution.colocate.scenarioPath = '.volter/handlers/openai.json';
writeFileSync(path, JSON.stringify(config, null, 2) + '\n');
console.log('Selected .volter/handlers/openai.json');
```

```bash
node configure-scenario.mjs
```

Edit handlers while the World is stopped; restart it to load changes. The
[scenario guide](../../docs/guides/shape-the-world-for-a-test.md) explains matches, misses and
authored refusals. This example uses the Chat Completions API supported by the pinned SDK and
twin. OpenAI's [function-calling guide](https://developers.openai.com/api/docs/guides/function-calling)
explains why the application executes the tool, and the
[Chat Completions reference](https://developers.openai.com/api/reference/resources/chat/subresources/completions/methods/create)
describes the `tools`, `tool_call_id` and `stream` fields used here.

## Create the issue and stream the result

Save the application below. It permits one known tool with two bounded text arguments. The
repository is fixed by the application under the authenticated synthetic account; a model
response cannot choose another owner or repository. The app creates that repository if absent.

`outcome.json` is the application's receipt. Immediately after a successful issue response,
the app saves its returned ID and number before requesting the streamed confirmation. A later
`continue` uses the saved issue and conversation. A pending issue request without a saved
response refuses continuation because its write outcome is unknown.

```js file=workflow.mjs
import { existsSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import OpenAI from 'openai';
import { Octokit } from '@octokit/rest';

if (!process.env.VOLTER_WORLD) throw new Error('Run this application through the World');
if (!process.env.OPENAI_API_KEY || !process.env.GITHUB_TOKEN) {
  throw new Error('Expected the World-provided throwaway credentials');
}

const mode = process.argv[2];
if (!['create', 'continue', 'read'].includes(mode)) {
  throw new Error('Use workflow.mjs create, continue, or read');
}
const receiptPath = 'outcome.json';
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY, maxRetries: 0 });
const github = new Octokit({ auth: process.env.GITHUB_TOKEN });
const tool = {
  type: 'function',
  function: {
    name: 'create_issue',
    description: 'Create one issue in the application-owned triage repository.',
    parameters: {
      type: 'object',
      properties: { title: { type: 'string' }, body: { type: 'string' } },
      required: ['title', 'body'],
      additionalProperties: false
    }
  }
};

function save(receipt) {
  writeFileSync(receiptPath + '.tmp', JSON.stringify(receipt, null, 2) + '\n', { mode: 0o600 });
  renameSync(receiptPath + '.tmp', receiptPath);
}

function argumentsFor(call) {
  if (call?.type !== 'function' || call.function?.name !== 'create_issue' || !call.id) {
    throw new Error('Refused an unknown tool call');
  }
  const args = JSON.parse(call.function.arguments);
  if (!args || typeof args !== 'object' || Array.isArray(args) ||
      Object.keys(args).sort().join(',') !== 'body,title' ||
      typeof args.title !== 'string' || !args.title.trim() || args.title.length > 120 ||
      typeof args.body !== 'string' || !args.body.trim() || args.body.length > 5000) {
    throw new Error('Refused malformed create_issue arguments');
  }
  return { title: args.title, body: args.body };
}

let receipt = existsSync(receiptPath) ? JSON.parse(readFileSync(receiptPath, 'utf8')) : null;
if (mode === 'create') {
  if (receipt) throw new Error('A receipt already exists; use continue or read');
  const owner = (await github.users.getAuthenticated()).data.login;
  if (!owner) throw new Error('The authenticated GitHub account has no login');
  receipt = {
    phase: 'prepared',
    owner,
    repo: 'tool-triage',
    model: 'gpt-4o-mini',
    messages: [{ role: 'user', content: 'File the duplicate payment receipt as a GitHub issue.' }]
  };
  save(receipt);
} else if (!receipt) {
  throw new Error('No outcome.json exists; first run create');
}

if (mode !== 'read') {
  if (receipt.phase === 'issue-write-pending' && !receipt.issue) {
    throw new Error('Issue write outcome is unknown; inspect the World log before resolving the receipt');
  }
  if (!receipt.repositoryReady) {
    try {
      await github.repos.get({ owner: receipt.owner, repo: receipt.repo });
    } catch (error) {
      if (error.status !== 404) throw error;
      await github.repos.createForAuthenticatedUser({
        name: receipt.repo, private: true, description: 'Synthetic tool-call triage example'
      });
    }
    receipt.repositoryReady = true;
    save(receipt);
  }
  if (!receipt.toolCall) {
    const completion = await openai.chat.completions.create({
      model: receipt.model, messages: receipt.messages, tools: [tool], store: true, max_tokens: 128
    });
    receipt.initialCompletionId = completion.id;
    receipt.initialAnswer = completion.choices[0]?.message ?? null;
    save(receipt);
    const calls = receipt.initialAnswer?.tool_calls;
    if (!Array.isArray(calls) || calls.length !== 1) {
      throw new Error('Expected one authored tool call; no issue was created');
    }
    receipt.arguments = argumentsFor(calls[0]);
    receipt.toolCall = calls[0];
    receipt.messages.push({
      role: 'assistant', content: receipt.initialAnswer.content, tool_calls: [receipt.toolCall]
    });
    receipt.phase = 'tool-ready';
    save(receipt);
  }
  if (!receipt.issue) {
    // Revalidate the retained proposal before the stored mutation.
    const args = argumentsFor(receipt.toolCall);
    receipt.phase = 'issue-write-pending';
    save(receipt);
    const { data: issue } = await github.issues.create({
      owner: receipt.owner, repo: receipt.repo, ...args
    });
    receipt.issue = {
      id: issue.id, number: issue.number, url: issue.html_url,
      owner: receipt.owner, repo: receipt.repo, title: issue.title, body: issue.body
    };
    receipt.phase = 'issue-created';
    save(receipt);
    console.log('GitHub issue recorded in outcome.json');
  }
  if (!receipt.toolResult) {
    receipt.toolResult = {
      created: true, owner: receipt.issue.owner, repo: receipt.issue.repo,
      number: receipt.issue.number, url: receipt.issue.url, title: receipt.issue.title
    };
    receipt.messages.push({
      role: 'tool', tool_call_id: receipt.toolCall.id, content: JSON.stringify(receipt.toolResult)
    });
    save(receipt);
  }
  if (receipt.phase !== 'complete') {
    receipt.phase = 'confirmation-pending';
    save(receipt);
    const stream = await openai.chat.completions.create({
      model: receipt.model, messages: receipt.messages, tools: [tool], tool_choice: 'none',
      store: true, stream: true, max_tokens: 128
    });
    let text = '';
    let finishReason = null;
    for await (const chunk of stream) {
      if (chunk.id && chunk.id !== receipt.finalCompletionId) {
        receipt.finalCompletionId = chunk.id;
        save(receipt);
      }
      const choice = chunk.choices[0];
      if (choice?.delta?.tool_calls) throw new Error('Refused another tool call during confirmation');
      const content = choice?.delta?.content ?? '';
      text += content;
      process.stdout.write(content);
      if (choice?.finish_reason) finishReason = choice.finish_reason;
    }
    process.stdout.write('\n');
    if (!receipt.finalCompletionId || finishReason !== 'stop') {
      throw new Error('Confirmation was incomplete; the saved issue remains available');
    }
    receipt.confirmation = text;
    receipt.phase = 'complete';
    save(receipt);
  } else {
    console.log('Saved workflow is complete; no issue or chat request repeated');
  }
  console.log(JSON.stringify({ phase: receipt.phase, issue: receipt.issue }, null, 2));
} else {
  if (!receipt.issue) throw new Error('No successful issue response is saved in the receipt');
  const { data: issue } = await github.issues.get({
    owner: receipt.issue.owner, repo: receipt.issue.repo, issue_number: receipt.issue.number
  });
  console.log('Stored GitHub issue');
  console.log(JSON.stringify({
    id: issue.id, number: issue.number, title: issue.title, body: issue.body,
    state: issue.state, url: issue.html_url
  }, null, 2));
  for (const [label, id] of [
    ['Initial tool call', receipt.initialCompletionId],
    ['Streamed confirmation', receipt.finalCompletionId]
  ]) {
    if (!id) continue;
    const completion = await openai.chat.completions.retrieve(id);
    const messages = await openai.chat.completions.messages.list(id);
    console.log(label + ' — stored request and answer');
    console.log(JSON.stringify({ id: completion.id, messages: messages.data, choices: completion.choices }, null, 2));
  }
  console.log('OpenAI scenario diagnostic');
  try {
    const response = await fetch(`${process.env.OPENAI_TWIN_URL}/twin/scenario`);
    console.log(`HTTP ${response.status}`);
    console.log(await response.text());
    if (!response.ok) {
      console.error('Scenario diagnostic refused; the stored vendor readbacks above completed.');
    }
  } catch (error) {
    console.error(`Scenario diagnostic failed: ${error.message}`);
  }
}
```

```bash
./node_modules/.bin/volter world up
./node_modules/.bin/volter world clock set 2026-01-15T12:00:00Z
./node_modules/.bin/volter world run -- node workflow.mjs create
```

The expected successful output begins with:

```text
GitHub issue recorded in outcome.json
Scripted confirmation: the application returned a stored GitHub issue. Its number and URL are in outcome.json.
```

The following JSON prints the returned issue ID, number and URL rather than a guessed value.
`gpt-4o-mini` is the model modeled by these pinned twins at the frozen World time; this example
makes no claim about a live model's current availability or quality. Stream chunks come from
the authored response through the ordinary SDK's async iterator, as described by OpenAI's
[streaming guide](https://developers.openai.com/api/docs/guides/streaming-responses).

## Inspect the prompt, tool result and stored issue

```bash
./node_modules/.bin/volter world run -- node workflow.mjs read
./node_modules/.bin/volter world log
```

`read` uses `issues.get`, `chat.completions.retrieve` and
`chat.completions.messages.list` to print stored vendor data. Both chat requests use `store: true`;
the stream's completion ID is retained from its chunks. The initial stored answer contains the
tool call; the final stored request includes the tool-result text; the final answer contains
the authored confirmation. The native log shows the repository and issue writes separately
from the chat requests.

This OpenAI release's stored-message view retains role, content and name, rather than every
tool-correlation field. Inspect `outcome.json` for the full application conversation, including
the assistant's tool call and matching `tool_call_id`. That receipt complements the SDK reads;
it does not stand in for them. The printed `/twin/scenario` response shows actual handler
matches and misses. Call the result an authored confirmation only when `request-issue` and
`issue-confirmation` matched the two requests. An unmatched labeled stub does not establish
that this flow ran, even if the app saved an issue and reached its `complete` phase.
The diagnostic's status and body are printed separately from the vendor reads. If it is
refused, keep that refusal visible; the completed SDK reads do not establish scenario counters.
Handler counts belong to the running scenario engine and restart when its process restarts.

## Stop and resume without repeating the mutation

```bash
./node_modules/.bin/volter world down
./node_modules/.bin/volter world up
./node_modules/.bin/volter world run -- node workflow.mjs read
./node_modules/.bin/volter world down
./node_modules/.bin/volter world status
```

`down` stops compute and retains the World's state. `up` resumes that state; `read` makes no
issue mutation or new completion request. Keep `outcome.json` with this World so the reads use
the same returned IDs. The final status should show the stopped World.

If confirmation failed after the issue response was saved, resume the stopped World and run:

```bash
./node_modules/.bin/volter world up
./node_modules/.bin/volter world run -- node workflow.mjs continue
./node_modules/.bin/volter world run -- node workflow.mjs read
./node_modules/.bin/volter world down
```

`continue` skips the saved issue mutation and sends its actual result to the model again if
confirmation remains incomplete. It does not retry an issue request whose outcome is unknown.
For that pending boundary, inspect the native log and stored repository before deliberately
resolving the receipt; deleting it and rerunning can create a duplicate. Malformed or unknown
tool calls refuse before issue creation. API refusals stay visible because OpenAI automatic
retries are disabled. A successful GitHub mutation survives a later model refusal or stream
failure; the receipt describes that partial outcome.

The World simulates these two vendors for this app. It does not measure production GitHub
permissions, a live model's tool choice, or enforce network isolation for unrelated binaries.
For a fresh workflow, [reset deliberately](../../docs/guides/seed-and-reset.md) and start with a
new receipt rather than confusing a new World state with this retained issue.

<!-- Fenced walkthrough files and commands; execution evidence is retained separately. -->
