6 [`shell`](https://developers.openai.com/api/docs/guides/tools-shell) tool with GPT-5.1 instead. [Learn6 [`shell`](https://developers.openai.com/api/docs/guides/tools-shell) tool with GPT-5.1 instead. [Learn
7 more](https://developers.openai.com/api/docs/guides/tools-shell).7 more](https://developers.openai.com/api/docs/guides/tools-shell).
8 8
9Local shell is a tool that allows agents to run shell commands locally on a machine you or the user provides. It's designed to work with [Codex CLI](https://github.com/openai/codex) and [`codex-mini-latest`](https://developers.openai.com/api/docs/models/codex-mini-latest). Commands are executed inside your own runtime, **you are fully in control of which commands actually run** —the API only returns the instructions, but does not execute them on OpenAI infrastructure.9Local shell is a tool that allows agents to run shell commands locally on a machine you or the user provides. It's designed to work with [Codex CLI](https://github.com/openai/codex) and [`codex-mini-latest`](https://developers.openai.com/api/docs/models/codex-mini-latest). Commands are executed inside your own runtime, so **you are fully in control of which commands actually run**. The API only returns instructions; it does not execute them on OpenAI infrastructure.
10 10
11Local shell is available through the [Responses API](https://developers.openai.com/api/docs/guides/migrate-to-responses) for use with [`codex-mini-latest`](https://developers.openai.com/api/docs/models/codex-mini-latest). It is not available on other models, or via the Chat Completions API.11Local shell is available through the [Responses API](https://developers.openai.com/api/docs/guides/migrate-to-responses) for use with [`codex-mini-latest`](https://developers.openai.com/api/docs/models/codex-mini-latest). It is not available on other models or via the Chat Completions API.
12 12
13Running arbitrary shell commands can be dangerous. Always sandbox execution13Running arbitrary shell commands can be dangerous. Always sandbox execution
14or add strict allow- / deny-lists before forwarding a command to the system14or add strict allowlists or deny lists before forwarding a command to the system
15shell.15shell.
16 16
17 17
22 22
23The local shell tool enables agents to run in a continuous loop with access to a terminal.23The local shell tool enables agents to run in a continuous loop with access to a terminal.
24 24
25It sends shell commands, which your code executes on a local machine and then returns the output back to the model. This loop allows the model to complete the build-test-run loop without additional intervention by a user.25The model sends shell commands, which your code executes on a local machine before returning the output to the model. This loop allows the model to complete the build-test-run loop without additional user intervention.
26 26
27As part of your code, you'll need to implement a loop that listens for `local_shell_call` output items and executes the commands they contain. We strongly recommend sandboxing the execution of these commands to prevent any unexpected commands from being executed.27Your code must implement a loop that listens for `local_shell_call` output items and executes the commands they contain. We strongly recommend sandboxing execution to prevent unexpected commands from running.
28 28
29 29
30 30
32 32
33 33
34 34
35These are the high-level steps you need to follow to integrate the computer use tool in your application:35These are the high-level steps you need to follow to integrate the local shell tool in your application:
36 36
371. **Send a request to the model**:371. **Send a request to the model**:
38 Include the `local_shell` tool as part of the available tools.38 Include the `local_shell` tool as part of the available tools.
42 This tool call contains an action like `exec` with a command to execute.42 This tool call contains an action like `exec` with a command to execute.
43 43
443. **Execute the requested action**:443. **Execute the requested action**:
45 Execute through code the corresponding action in the computer or container environment.45 Run the command in the local environment you control.
46 46
474. **Return the action output**:474. **Return the action output**:
48 After executing the action, return the command output and metadata like status code to the model.48 After executing the action, return the command output to the model.
49 49
505. **Repeat**:505. **Repeat**:
51 Send a new request with the updated state as a `local_shell_call_output`, and repeat this loop until the model stops requesting actions or you decide to stop.51 Send a new request with the updated state as a `local_shell_call_output`, and repeat this loop until the model stops requesting actions or you decide to stop.
52 52
53## Example workflow53## Example workflow
54 54
55Below is a minimal (Python) example showing the request/response loop. For55Below is a minimal example showing the request/response loop. Choose a language
56brevity, error handling and security checks are omitted—**do not execute56to see the equivalent workflow for its SDK. For brevity, production-grade
57untrusted commands in production without additional safeguards**.57sandboxing and security checks are omitted—**do not execute untrusted commands
58in production without additional safeguards**.
59
60```javascript
61import { spawn } from "node:child_process";
62import process from "node:process";
63import OpenAI from "openai";
64
65const client = new OpenAI();
66const MAX_TIMEOUT_MS = 10_000;
67
68function runCommand(command, options) {
69 return new Promise((resolve) => {
70 let stdout = "";
71 let stderr = "";
72 let settled = false;
73 let groupPoll;
74 const child = spawn(command[0], command.slice(1), {
75 ...options,
76 detached: process.platform !== "win32",
77 stdio: ["ignore", "pipe", "pipe"],
78 });
79 const finish = (suffix = "") => {
80 if (settled) return;
81 settled = true;
82 clearTimeout(timer);
83 clearTimeout(groupPoll);
84 resolve(stdout + stderr + suffix);
85 };
86 const processGroupIsRunning = () => {
87 if (process.platform === "win32" || !child.pid) return false;
88 try {
89 process.kill(-child.pid, 0);
90 return true;
91 } catch {
92 return false;
93 }
94 };
95 const finishAfterProcessGroup = (suffix) => {
96 if (settled) return;
97 if (processGroupIsRunning()) {
98 groupPoll = setTimeout(() => finishAfterProcessGroup(suffix), 10);
99 } else {
100 finish(suffix);
101 }
102 };
103 const killProcessTree = () => {
104 try {
105 if (process.platform !== "win32" && child.pid) {
106 process.kill(-child.pid, "SIGKILL");
107 } else {
108 child.kill("SIGKILL");
109 }
110 } catch {
111 child.kill("SIGKILL");
112 }
113 child.stdout?.destroy();
114 child.stderr?.destroy();
115 };
116 const timer = setTimeout(() => {
117 killProcessTree();
118 finish("Command timed out.\n");
119 }, options.timeout);
120
121 child.stdout?.on("data", (chunk) => {
122 stdout += chunk;
123 });
124 child.stderr?.on("data", (chunk) => {
125 stderr += chunk;
126 });
127 child.on("error", (error) => {
128 finish(`Command failed: ${error.message}.\n`);
129 });
130 child.on("close", (code, signal) => {
131 if (signal) {
132 finishAfterProcessGroup(`Command failed with signal ${signal}.\n`);
133 } else if (code !== 0) {
134 finishAfterProcessGroup(`Command failed with exit code ${code}.\n`);
135 } else {
136 finishAfterProcessGroup("");
137 }
138 });
139 });
140}
141
142let response = await client.responses.create({
143 model: "codex-mini-latest",
144 tools: [{ type: "local_shell" }],
145 parallel_tool_calls: false,
146 input: "List files in the current directory.",
147});
148
149while (true) {
150 const shellCall = response.output.find(
151 (item) => item.type === "local_shell_call"
152 );
153 if (!shellCall) break;
154
155 const { command, env, timeout_ms, user, working_directory } =
156 shellCall.action;
157 let output;
158 if (user) {
159 output = `Unsupported execution user: ${user}.\n`;
160 } else if (command.length === 0) {
161 output = "Command is empty.\n";
162 } else {
163 const timeout =
164 timeout_ms && timeout_ms > 0
165 ? Math.min(timeout_ms, MAX_TIMEOUT_MS)
166 : MAX_TIMEOUT_MS;
167 try {
168 output = await runCommand(command, {
169 cwd: working_directory ?? process.cwd(),
170 env: { PATH: process.env.PATH ?? "", ...env },
171 timeout,
172 });
173 } catch (error) {
174 output = `Command failed: ${error instanceof Error ? error.message : String(error)}.\n`;
175 }
176 }
177
178 response = await client.responses.create({
179 model: "codex-mini-latest",
180 tools: [{ type: "local_shell" }],
181 parallel_tool_calls: false,
182 previous_response_id: response.id,
183 input: [
184 {
185 type: "local_shell_call_output",
186 id: shellCall.call_id,
187 output,
188 },
189 ],
190 });
191}
192
193console.log(response.output_text);
194```
58 195
59```python196```python
60import os197import os
61import shlex198import signal
62import subprocess199import subprocess
200import time
201from contextlib import suppress
63from openai import OpenAI202from openai import OpenAI
64 203
65client = OpenAI()204client = OpenAI()
205MAX_TIMEOUT_MS = 10_000
206
207
208def output_text(value):
209 if isinstance(value, bytes):
210 return value.decode(errors="replace")
211 return value or ""
212
213
214def process_group_is_running(pid):
215 if os.name == "nt":
216 return False
217 try:
218 os.killpg(pid, 0)
219 return True
220 except ProcessLookupError:
221 return False
222 except PermissionError:
223 return True
224
66 225
67# 1) Create the initial response request with the tool enabled
68response = client.responses.create(226response = client.responses.create(
69 model="codex-mini-latest",227 model="codex-mini-latest",
70 tools=[{"type": "local_shell"}],228 tools=[{"type": "local_shell"}],
71 input=[229 parallel_tool_calls=False,
72 {230 input="List files in the current directory.",
73 "role": "user",
74 "content": [
75 {"type": "input_text", "text": "List files in the current directory"},
76 ],
77 }
78 ],
79)231)
80 232
81while True:233while True:
82 # 2) Look for a local_shell_call in the model's output items234 shell_call = next(
83 shell_calls = []235 (item for item in response.output if item.type == "local_shell_call"),
84 for item in response.output:236 None,
85 item_type = getattr(item, "type", None)237 )
86 if item_type == "local_shell_call":238 if shell_call is None:
87 shell_calls.append(item)
88 elif (
89 item_type == "tool_call"
90 and getattr(item, "tool_name", None) == "local_shell"
91 ):
92 shell_calls.append(item)
93 if not shell_calls:
94 # No more commands — the assistant is done.
95 break239 break
96 240
97 call = shell_calls[0]241 action = shell_call.action
98 args = getattr(call, "action", None) or getattr(call, "arguments", None)242 if action.user:
99 243 output = f"Unsupported execution user: {action.user}.\n"
100 # 3) Execute the command locally (here we just trust the command!)244 elif not action.command:
101 # The command is already split into argv tokens.245 output = "Command is empty.\n"
102 def _get(obj, key, default=None):246 else:
103 if isinstance(obj, dict):247 timeout_ms = (
104 return obj.get(key, default)248 min(action.timeout_ms, MAX_TIMEOUT_MS)
105 return getattr(obj, key, default)249 if action.timeout_ms and action.timeout_ms > 0
106 250 else MAX_TIMEOUT_MS
107 timeout_ms = _get(args, "timeout_ms")251 )
108 command = _get(args, "command")252 deadline = time.monotonic() + timeout_ms / 1000
109 if not command:253 try:
110 break254 process = subprocess.Popen(
111 if isinstance(command, str):255 action.command,
112 command = shlex.split(command)256 cwd=action.working_directory or os.getcwd(),
113 completed = subprocess.run(257 env={"PATH": os.environ.get("PATH", ""), **action.env},
114 command,258 stdin=subprocess.DEVNULL,
115 cwd=_get(args, "working_directory") or os.getcwd(),259 stdout=subprocess.PIPE,
116 env={**os.environ, **(_get(args, "env") or {})},260 stderr=subprocess.PIPE,
117 capture_output=True,
118 text=True,261 text=True,
119 timeout=(timeout_ms / 1000) if timeout_ms else None,262 errors="replace",
263 start_new_session=True,
264 )
265 stdout, stderr = process.communicate(
266 timeout=max(deadline - time.monotonic(), 0)
267 )
268 while process_group_is_running(process.pid):
269 remaining = deadline - time.monotonic()
270 if remaining <= 0:
271 raise subprocess.TimeoutExpired(action.command, timeout_ms / 1000)
272 time.sleep(min(remaining, 0.01))
273 output = stdout + stderr
274 if process.returncode:
275 output += f"Command failed with exit code {process.returncode}.\n"
276 except subprocess.TimeoutExpired as error:
277 if os.name == "nt":
278 process.kill()
279 else:
280 with suppress(ProcessLookupError):
281 os.killpg(process.pid, signal.SIGKILL)
282 try:
283 stdout, stderr = process.communicate(
284 timeout=max(deadline - time.monotonic(), 0)
120 )285 )
286 except subprocess.TimeoutExpired as drain_error:
287 if process.stdout:
288 process.stdout.close()
289 if process.stderr:
290 process.stderr.close()
291 stdout = output_text(
292 drain_error.stdout
293 if drain_error.stdout is not None
294 else error.stdout
295 )
296 stderr = output_text(
297 drain_error.stderr
298 if drain_error.stderr is not None
299 else error.stderr
300 )
301 output = output_text(stdout) + output_text(stderr) + "Command timed out.\n"
302 except (OSError, TypeError, ValueError) as error:
303 output = f"Command failed: {error}.\n"
121 304
122 output_item = {305 output_item = {
123 "type": "local_shell_call_output",306 "type": "local_shell_call_output",
124 "call_id": getattr(call, "call_id", None),307 "id": shell_call.call_id,
125 "output": completed.stdout + completed.stderr,308 "output": output,
126 }309 }
127 310
128 # 4) Send the output back to the model to continue the conversation
129 response = client.responses.create(311 response = client.responses.create(
130 model="codex-mini-latest",312 model="codex-mini-latest",
131 tools=[{"type": "local_shell"}],313 tools=[{"type": "local_shell"}],
314 parallel_tool_calls=False,
132 previous_response_id=response.id,315 previous_response_id=response.id,
133 input=[output_item],316 input=[output_item],
134 )317 )
135 318
136# Print the assistant's final answer
137print(response.output_text)319print(response.output_text)
138```320```
139 321
322```go
323package main
324
325import (
326 "bytes"
327 "context"
328 "errors"
329 "fmt"
330 "io"
331 "os"
332 "os/exec"
333 "path/filepath"
334 "sync"
335 "sync/atomic"
336 "syscall"
337 "time"
338
339 "github.com/openai/openai-go/v3"
340 "github.com/openai/openai-go/v3/responses"
341)
342
343const maxCommandTimeout = 10 * time.Second
344
345func main() {
346 client := openai.NewClient()
347 tool := responses.ToolUnionParam{OfLocalShell: &responses.ToolLocalShellParam{}}
348 response, err := client.Responses.New(context.Background(), responses.ResponseNewParams{
349 Model: "codex-mini-latest",
350 Tools: []responses.ToolUnionParam{tool},
351 ParallelToolCalls: openai.Bool(false),
352 Input: responses.ResponseNewParamsInputUnion{
353 OfString: openai.String("List files in the current directory."),
354 },
355 })
356 if err != nil {
357 panic(err)
358 }
359
360 for {
361 var shellCall *responses.ResponseOutputItemLocalShellCall
362 for _, item := range response.Output {
363 if item.Type == "local_shell_call" {
364 call := item.AsLocalShellCall()
365 shellCall = &call
366 break
367 }
368 }
369 if shellCall == nil {
370 break
371 }
372
373 action := shellCall.Action
374 var output []byte
375 if action.User != "" {
376 output = []byte(fmt.Sprintf("Unsupported execution user: %s.\n", action.User))
377 } else if len(action.Command) == 0 {
378 output = []byte("Command is empty.\n")
379 } else {
380 path := os.Getenv("PATH")
381 if actionPath, ok := action.Env["PATH"]; ok {
382 path = actionPath
383 }
384 executable, pathErr := commandPath(action.Command[0], path, action.WorkingDirectory)
385 if pathErr != nil {
386 output = []byte(fmt.Sprintf("Command failed: %v\n", pathErr))
387 } else {
388 timeout := maxCommandTimeout
389 if action.TimeoutMs > 0 && action.TimeoutMs < maxCommandTimeout.Milliseconds() {
390 timeout = time.Duration(action.TimeoutMs) * time.Millisecond
391 }
392 deadline := time.Now().Add(timeout)
393 ctx, cancel := context.WithTimeout(context.Background(), timeout)
394 command := exec.CommandContext(ctx, executable, action.Command[1:]...)
395 command.Args[0] = action.Command[0]
396 command.Dir = action.WorkingDirectory
397 command.Env = []string{"PATH=" + path}
398 command.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
399 for key, value := range action.Env {
400 if key == "PATH" {
401 continue
402 }
403 command.Env = append(command.Env, key+"="+value)
404 }
405 killProcessGroup := func() {
406 if command.Process != nil {
407 _ = syscall.Kill(-command.Process.Pid, syscall.SIGKILL)
408 }
409 }
410 processGroupIsRunning := func() bool {
411 return command.Process != nil && syscall.Kill(-command.Process.Pid, 0) == nil
412 }
413 stdout, stdoutWriter, stdoutErr := os.Pipe()
414 stderr, stderrWriter, stderrErr := os.Pipe()
415 if stdoutErr != nil || stderrErr != nil {
416 if stdout != nil {
417 _ = stdout.Close()
418 }
419 if stdoutWriter != nil {
420 _ = stdoutWriter.Close()
421 }
422 if stderr != nil {
423 _ = stderr.Close()
424 }
425 if stderrWriter != nil {
426 _ = stderrWriter.Close()
427 }
428 output = []byte(fmt.Sprintf("Command failed: %v%v\n", stdoutErr, stderrErr))
429 } else {
430 command.Stdout = stdoutWriter
431 command.Stderr = stderrWriter
432 var combinedOutput bytes.Buffer
433 var outputLock sync.Mutex
434 var readers sync.WaitGroup
435 readOutput := func(reader io.ReadCloser) {
436 defer readers.Done()
437 data, _ := io.ReadAll(reader)
438 outputLock.Lock()
439 _, _ = combinedOutput.Write(data)
440 outputLock.Unlock()
441 }
442 var commandErr error
443 commandErr = command.Start()
444 if commandErr == nil {
445 _ = stdoutWriter.Close()
446 _ = stderrWriter.Close()
447 readers.Add(2)
448 go readOutput(stdout)
449 go readOutput(stderr)
450 var timedOut atomic.Bool
451 remaining := time.Until(deadline)
452 if remaining < 0 {
453 remaining = 0
454 }
455 markTimedOut := func() {
456 if timedOut.Swap(true) {
457 return
458 }
459 killProcessGroup()
460 _ = stdout.Close()
461 _ = stderr.Close()
462 }
463 timer := time.AfterFunc(remaining, markTimedOut)
464 commandErr = command.Wait()
465 if !time.Now().Before(deadline) ||
466 errors.Is(commandErr, context.DeadlineExceeded) ||
467 errors.Is(ctx.Err(), context.DeadlineExceeded) {
468 markTimedOut()
469 }
470 for processGroupIsRunning() && !timedOut.Load() {
471 time.Sleep(10 * time.Millisecond)
472 }
473 readers.Wait()
474 if !timer.Stop() || !time.Now().Before(deadline) {
475 markTimedOut()
476 }
477 output = combinedOutput.Bytes()
478 if timedOut.Load() || errors.Is(ctx.Err(), context.DeadlineExceeded) {
479 killProcessGroup()
480 output = append(output, "Command timed out.\n"...)
481 } else if commandErr != nil {
482 output = append(output, fmt.Sprintf("Command failed: %v\n", commandErr)...)
483 }
484 } else {
485 _ = stdout.Close()
486 _ = stderr.Close()
487 _ = stdoutWriter.Close()
488 _ = stderrWriter.Close()
489 output = append(output, fmt.Sprintf("Command failed: %v\n", commandErr)...)
490 }
491 }
492 cancel()
493 }
494 }
495
496 response, err = client.Responses.New(context.Background(), responses.ResponseNewParams{
497 Model: "codex-mini-latest",
498 Tools: []responses.ToolUnionParam{tool},
499 ParallelToolCalls: openai.Bool(false),
500 PreviousResponseID: openai.String(response.ID),
501 Input: responses.ResponseNewParamsInputUnion{
502 OfInputItemList: []responses.ResponseInputItemUnionParam{{
503 OfLocalShellCallOutput: &responses.ResponseInputItemLocalShellCallOutputParam{
504 ID: shellCall.CallID,
505 Output: string(output),
506 },
507 }},
508 },
509 })
510 if err != nil {
511 panic(err)
512 }
513 }
514
515 fmt.Println(response.OutputText())
516}
517
518func commandPath(command string, path string, workingDirectory string) (string, error) {
519 if filepath.Base(command) != command {
520 return command, nil
521 }
522 baseDirectory, err := filepath.Abs(workingDirectory)
523 if err != nil {
524 return "", err
525 }
526 directories := filepath.SplitList(path)
527 if len(directories) == 0 {
528 directories = []string{""}
529 }
530 for _, directory := range directories {
531 if directory == "" {
532 directory = "."
533 }
534 if !filepath.IsAbs(directory) {
535 directory = filepath.Join(baseDirectory, directory)
536 }
537 candidate := filepath.Join(directory, command)
538 info, err := os.Stat(candidate)
539 if err == nil && !info.IsDir() && info.Mode()&0o111 != 0 {
540 return candidate, nil
541 }
542 }
543 return "", fmt.Errorf("command %q not found in PATH", command)
544}
545```
546
547```java
548import com.openai.client.OpenAIClient;
549import com.openai.client.okhttp.OpenAIOkHttpClient;
550import com.openai.core.JsonValue;
551import com.openai.models.responses.ResponseCreateParams;
552import com.openai.models.responses.ResponseInputItem;
553import java.io.IOException;
554import java.io.UncheckedIOException;
555import java.nio.charset.StandardCharsets;
556import java.nio.file.Files;
557import java.nio.file.Path;
558import java.util.ArrayList;
559import java.util.LinkedHashMap;
560import java.util.List;
561import java.util.Locale;
562import java.util.Map;
563import java.util.concurrent.CompletableFuture;
564import java.util.concurrent.TimeUnit;
565import java.util.concurrent.TimeoutException;
566
567ResponseCreateParams.Builder request =
568 ResponseCreateParams.builder()
569 .model("codex-mini-latest")
570 .input("List files in the current directory.")
571 .parallelToolCalls(false)
572 .putAdditionalBodyProperty(
573 "tools", JsonValue.from(List.of(Map.of("type", "local_shell"))));
574var response = client.responses().create(request.build());
575
576while (true) {
577 var shellCall =
578 response.output().stream()
579 .flatMap(item -> item.localShellCall().stream())
580 .findFirst()
581 .orElse(null);
582 if (shellCall == null) {
583 break;
584 }
585
586 var action = shellCall.action();
587 String output;
588 if (action.user().isPresent()) {
589 output = "Unsupported execution user: " + action.user().get() + ".\n";
590 } else if (action.command().isEmpty()) {
591 output = "Command is empty.\n";
592 } else {
593 try {
594 boolean usesShellSupervisor =
595 !System.getProperty("os.name").toLowerCase(Locale.ROOT).startsWith("win");
596 String hostPath = System.getenv("PATH");
597 String childPath = hostPath;
598 Map<String, String> actionEnvironment = new LinkedHashMap<>();
599 for (Map.Entry<String, com.openai.core.JsonValue> variable :
600 action.env()._additionalProperties().entrySet()) {
601 String value = (String) variable.getValue().asString().orElseThrow();
602 actionEnvironment.put(variable.getKey(), value);
603 if (variable.getKey().equals("PATH")) {
604 childPath = value;
605 }
606 }
607 List<String> command;
608 if (usesShellSupervisor) {
609 String supervisorShell =
610 Files.isExecutable(Path.of("/bin/bash")) ? "/bin/bash" : "/bin/sh";
611 command =
612 new ArrayList<>(
613 List.of(
614 supervisorShell,
615 "-c",
616 "set -m; child=; "
617 + "cleanup() { test -z \"$child\" || "
618 + "kill -KILL -- \"-$child\" 2>/dev/null; }; "
619 + "trap cleanup TERM INT HUP; \"$@\" & child=$!; set +m; "
620 + "wait \"$child\" 2>/dev/null; status=$?; "
621 + "while kill -0 -- \"-$child\" 2>/dev/null; do sleep 0.01; done; "
622 + "exit \"$status\"",
623 "local-shell",
624 "/usr/bin/env",
625 "-i"));
626 if (childPath != null) {
627 command.add("PATH=" + childPath);
628 }
629 for (Map.Entry<String, String> variable : actionEnvironment.entrySet()) {
630 if (!variable.getKey().equals("PATH")) {
631 command.add(variable.getKey() + "=" + variable.getValue());
632 }
633 }
634 command.addAll(action.command());
635 } else {
636 command = new ArrayList<>(action.command());
637 }
638 ProcessBuilder processBuilder = new ProcessBuilder(command);
639 processBuilder.directory(action.workingDirectory().map(java.io.File::new).orElse(null));
640 processBuilder.environment().clear();
641 if (hostPath != null) {
642 processBuilder.environment().put("PATH", hostPath);
643 }
644 if (!usesShellSupervisor) {
645 processBuilder.environment().putAll(actionEnvironment);
646 }
647 Process process = processBuilder.redirectErrorStream(true).start();
648 process.getOutputStream().close();
649 var outputFuture =
650 CompletableFuture.supplyAsync(
651 () -> {
652 try {
653 return new String(
654 process.getInputStream().readAllBytes(), StandardCharsets.UTF_8);
655 } catch (IOException error) {
656 throw new UncheckedIOException(error);
657 }
658 });
659 long timeoutMillis =
660 action
661 .timeoutMs()
662 .filter(timeout -> timeout > 0)
663 .map(timeout -> Math.min(timeout, MAX_TIMEOUT_MILLIS))
664 .orElse(MAX_TIMEOUT_MILLIS);
665 long deadlineNanos = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMillis);
666 boolean finished = process.waitFor(timeoutMillis, TimeUnit.MILLISECONDS);
667 if (!finished) {
668 destroyProcessTree(process, usesShellSupervisor);
669 }
670 try {
671 long remainingNanos = Math.max(1, deadlineNanos - System.nanoTime());
672 output = outputFuture.get(remainingNanos, TimeUnit.NANOSECONDS);
673 if (!finished) {
674 output = "Command timed out.\n" + output;
675 } else if (process.exitValue() != 0) {
676 output += "Command failed with exit code " + process.exitValue() + ".\n";
677 }
678 } catch (TimeoutException error) {
679 destroyProcessTree(process, usesShellSupervisor);
680 process.getInputStream().close();
681 output = "Command timed out.\n";
682 } catch (java.util.concurrent.ExecutionException error) {
683 output = "Command failed: " + error.getCause().getMessage() + ".\n";
684 }
685 } catch (IOException | IllegalArgumentException error) {
686 output = "Command failed: " + error.getMessage() + ".\n";
687 }
688 }
689
690 response =
691 client
692 .responses()
693 .create(
694 request
695 .previousResponseId(response.id())
696 .inputOfResponse(
697 List.of(
698 ResponseInputItem.ofLocalShellCallOutput(
699 ResponseInputItem.LocalShellCallOutput.builder()
700 .id(shellCall.callId())
701 .output(output)
702 .build())))
703 .build());
704}
705
706response.output().stream()
707 .flatMap(item -> item.message().stream())
708 .flatMap(message -> message.content().stream())
709 .flatMap(content -> content.outputText().stream())
710 .forEach(text -> System.out.println(text.text()));
711
712private static void destroyProcessTree(Process process, boolean usesShellSupervisor) {
713 process.descendants().forEach(ProcessHandle::destroyForcibly);
714 if (usesShellSupervisor) {
715 process.destroy();
716 } else {
717 process.destroyForcibly();
718 }
719}
720```
721
722```ruby
723require "open3"
724require "openai"
725require "timeout"
726
727client = OpenAI::Client.new
728MAX_TIMEOUT_MS = 10_000
729response = client.responses.create(
730 model: "codex-mini-latest",
731 tools: [{type: :local_shell}],
732 parallel_tool_calls: false,
733 input: "List files in the current directory."
734)
735
736loop do
737 shell_call = response.output.find do |item|
738 item.is_a?(OpenAI::Models::Responses::ResponseOutputItem::LocalShellCall)
739 end
740 break unless shell_call.is_a?(
741 OpenAI::Models::Responses::ResponseOutputItem::LocalShellCall
742 )
743
744 action = shell_call.action
745 stdout = +""
746 stderr = +""
747 if action.user
748 stderr << "Unsupported execution user: #{action.user}.\n"
749 elsif action.command.empty?
750 stderr << "Command is empty.\n"
751 else
752 begin
753 executable = action.command.fetch(0)
754 environment = {"PATH" => ENV.fetch("PATH", "")}.merge(action.env.transform_keys(&:to_s))
755 status, timed_out = Open3.popen3(
756 environment,
757 [executable, executable],
758 *action.command.drop(1),
759 chdir: action.working_directory || Dir.pwd,
760 pgroup: true,
761 unsetenv_others: true
762 ) do |stdin, child_stdout, child_stderr, wait_thread|
763 stdin.close
764 stdout_reader = Thread.new {
765 begin
766 child_stdout.read
767 rescue
768 ""
769 end
770 }
771 stderr_reader = Thread.new {
772 begin
773 child_stderr.read
774 rescue
775 ""
776 end
777 }
778 timeout_ms = action.timeout_ms
779 timeout = if timeout_ms&.positive?
780 [timeout_ms, MAX_TIMEOUT_MS].min / 1000.0
781 else
782 MAX_TIMEOUT_MS / 1000.0
783 end
784 deadline = Process.clock_gettime(Process::CLOCK_MONOTONIC) + timeout
785
786 command_timed_out = false
787 wait_status = begin
788 status = Timeout.timeout(timeout) { wait_thread.value }
789 remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
790 raise Timeout::Error if remaining <= 0
791
792 stdout << Timeout.timeout(remaining) { stdout_reader.value }
793 remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
794 raise Timeout::Error if remaining <= 0
795
796 stderr << Timeout.timeout(remaining) { stderr_reader.value }
797 group_running = proc do
798 Process.kill(0, -wait_thread.pid)
799 true
800 rescue Errno::ESRCH
801 false
802 rescue Errno::EPERM
803 true
804 end
805 while group_running.call
806 remaining = deadline - Process.clock_gettime(Process::CLOCK_MONOTONIC)
807 raise Timeout::Error if remaining <= 0
808
809 sleep [remaining, 0.01].min
810 end
811 status
812 rescue Timeout::Error
813 command_timed_out = true
814 begin
815 Process.kill("TERM", -wait_thread.pid)
816 Process.kill("KILL", -wait_thread.pid)
817 rescue Errno::ESRCH
818 nil
819 end
820 child_stdout.close
821 child_stderr.close
822 stdout_reader.kill
823 stderr_reader.kill
824 stderr << "Command timed out.\n"
825 wait_thread.value
826 end
827 [wait_status, command_timed_out]
828 end
829 exit_status = status.exitstatus
830 if exit_status && !status.success? && !timed_out
831 stderr << "Command failed with exit code #{exit_status}.\n"
832 elsif status.signaled? && !timed_out
833 stderr << "Command failed with signal #{status.termsig}.\n"
834 end
835 rescue SystemCallError, ArgumentError, TypeError => error
836 stderr << "Command failed: #{error.message}.\n"
837 end
838 end
839
840 response = client.responses.create(
841 model: "codex-mini-latest",
842 tools: [{type: :local_shell}],
843 parallel_tool_calls: false,
844 previous_response_id: response.id,
845 input: [{
846 type: :local_shell_call_output,
847 id: shell_call.call_id,
848 output: (stdout + stderr).encode("UTF-8", invalid: :replace, undef: :replace)
849 }]
850 )
851end
852
853puts(response.output_text)
854```
855
140 856
141## Best practices857## Best practices
142 858
143- **Sandbox or containerize** execution. Consider using Docker, firejail, or a859- **Sandbox or containerize** execution. Consider using Docker or a jailed user
144 jailed user account.860 account.
145- **Impose resource limits** (time, memory, network). The `timeout_ms`861- **Impose resource limits** (time, memory, network). The `timeout_ms`
146 provided by the model is only a hint—you should enforce your own limits.862 provided by the model is only a hint—you should enforce your own limits.
147- **Filter or scrutinize** high-risk commands (e.g. `rm`, `curl`, network863- **Filter or scrutinize** high-risk commands (for example, `rm`, `curl`, network
148 utilities).864 utilities).
149- **Log every command and its output** for auditability and debugging.865- **Log every command and its output** for auditing and debugging.
150 866
151### Error handling867### Error handling
152 868
153If the command fails on your side (non-zero exit code, timeout, etc.) you can still send a `local_shell_call_output`; include the error message in the `output` field.869If the command fails on your side, for example, with a non-zero exit code or timeout, you can still send a `local_shell_call_output`; include the error message in the `output` field.
154 870
155The model can choose to recover or try executing a different command. If you send malformed data (e.g. missing `call_id`) the API returns a standard `400` validation error.871The model can choose to recover or try executing a different command. If you send malformed data (for example, a missing `id`) the API returns a standard `400` validation error.