Tool server API
Lists the tools and the routes of the tool server of a workspace.
Last updated
On this page
- Reaching it
- The HTTP shape
- GET /tools
- POST /tools/{name}
- Working directories and paths
- Other routes
- Files
- read
- write
- edit
- patch
- glob
- grep
- Commands and processes
- exec
- process_list
- process_output
- process_write
- process_close_stdin
- process_wait
- process_kill
- Watches
- watch
- watch_poll
- watch_stop
- Containers
- container_build
- container_push
- Confinement
- Tasks and journal
- task
- Streams
Reaching it
The address comes from GET /v1/workspaces/{id}/tools, which answers the workspace's owner alone:
{ "address": "10.42.1.17:7788", "token": "eyJhbGciOi…" }The address is reachable from your desk and through the ssh tunnel. From a laptop, kode workspaces ide <workspace> opens the tunnel and prints the local address, normally http://localhost:7788.
Every route except /healthz needs Authorization: Bearer <token> with the workspace's own token. The ssh tunnel sends none, so a tunnel alone is not enough. Get the token:
- From outside, the
tokenfield of the response above. It is left out until the platform has written it. - Inside the workspace, the file
/etc/kode/ssh/workspace-token. The server reads it on every request. The platform renews it about every five minutes. Read it again after a401instead of keeping a copy.
A request with no token, a wrong token, or a server that cannot read its own token file is 401 with WWW-Authenticate: Bearer and {"error": "the tool server needs the workspace's token"}. The examples below assume:
export TOKEN=<the workspace token>export TOOLS=http://localhost:7788A capsule has no tool server. It runs one command, and you read its output with logs or type to it with attach.
The HTTP shape
GET /tools
Lists every tool with its JSON schema, and the state of the workspace's fences.
curl -H "Authorization: Bearer $TOKEN" $TOOLS/tools{ "tools": [{ "name": "read", "description": "Read text files with line numbers. …", "schema": { "type": "object", "properties": {}, "required": [] } }], "fence": { "capsule": "active", "network": "open" }}fence.capsule is active when exec runs inside the capsule wrapper, and unavailable when it runs unwrapped. fence.network is fenced when the outgoing-traffic fence is up, otherwise open.
POST /tools/{name}
Runs one tool. The JSON body is the tool's arguments; no body means no arguments.
curl -X POST -H "Authorization: Bearer $TOKEN" $TOOLS/tools/exec -d '{"cmd":"ls"}'{ "exit_code": 0, "stdout": "Cargo.toml\nsrc\n", "stderr": "", "truncated": false, "timed_out": false, "ms": 6 }A tool's own answer is the response body with status 200. Anything that stopped it from running is a status with {"error": "…"}:
| Status | Meaning |
|---|---|
400 | A missing required argument, an argument of the wrong type, a value outside an enum, an unknown tree, or an absolute path outside the working directory |
401 | No or wrong token |
403 | A path that resolves outside the working directory |
404 | An unknown tool |
413 | A body over about 14 MiB, the file limit in base64 plus room for the rest of the JSON |
500 | The tool ran and failed |
Arguments are checked against the schema once, before any tool runs, and the error names the field. The server accepts a JSON-encoded array or object in a string where the schema declares one. It accepts a decimal string for a number and "true" for a flag. Only top-level keys are checked; nested shapes are checked by the tool.
Working directories and paths
Every tool takes an optional tree: the name of an extra working directory under the .agents/ folder of the workspace. Left out, or main, it means the workspace itself. ?tree=name on the URL does the same, and a tree in the body wins when both are given. Every path in and out of a call is relative to the root of the working directory, which is also the default cwd. An absolute path under the root is accepted as that path relative to the root. Any other absolute path is 400, and a path that resolves outside the root, symlinks followed, is 403 naming the path.
Files are limited to 10 MiB for read and write. At most 50 files go in one read or edit call.
Other routes
| Route | Answer |
|---|---|
GET /healthz | {"ok": true, "root": "<workspace>", "trees": ["main"]} with no token. trees lists the working directories served so far, main first |
POST /hold | 204. Counts as activity, so the workspace's idle stop waits. For a long local job that holds no connection |
GET /stream/process/{id} | WebSocket, see Streams |
GET /stream/process/{id}/lines | The same frames as NDJSON over plain HTTP |
GET /stream/watch/{id} | WebSocket, see Streams |
GET /fs/raw?path=&offset=&len= answers bytes offset to offset + len of a regular file as application/octet-stream, with the size of the whole file in the x-kl-size header. len is at most 4 MiB (4194304, the default). A range past the end is empty. It is how the platform moves a file (a download file route, resources/read, transfer) without the bytes going through a tool call; it is not a tool.
POST /fs/upload?path=&id=&offset= writes the raw request body, at most 4 MiB, at offset of an upload named id (8 to 64 letters, digits, or dashes). The bytes go to a hidden file beside the target. &commit=1 on the last range renames it into place, so the target is never half written. offset must equal the bytes the upload already holds, else 409. A target that exists is 409 unless &overwrite=1. A file over 200 MB is 413. &abort=1 deletes the upload. The answer of a commit is {path, bytes, created}; it is the destination half of transfer and is not a tool.
/fs/tree, /fs/stat, /fs/file, /fs/git, /fs/changes, /fs/diff, and /fs/log also exist. They are what a console renders a workspace from. They are read-only, answer with an ETag, and honor If-None-Match, are not tools, and never appear in GET /tools.
Files
read
Read text files with line numbers. An image answers its bytes as base64, and any other binary file answers its size and mime type and no bytes.
| Argument | Type | Required | Rule |
|---|---|---|---|
path | string | no | One file. Give path or paths |
paths | array | no | 1 to 50 entries, each a path string or {path, offset, limit}. Each file gets its own answer |
offset | integer | no | First line, 1-based. Default 1 |
limit | integer | no | Lines to return. Default 2000 |
tree | string | no |
Result for one file: {path, content, total_lines, truncated}, each line prefixed with its number and a tab. truncated is true when lines remain past limit. For a PNG, JPEG, GIF, or WebP, decided by the first bytes and never by the name: {path, size, image: {mime, data_b64, width, height}}; width and height are given for PNG and JPEG. The base64 of one image, and of all the images of one paths call, is at most 5 MB: a PNG or JPEG over it is downscaled to fit and the answer adds downscaled_from: {width, height}, and an image that cannot fit answers {path, binary: true, size, mime, hint}. For any other binary file: {path, binary: true, size, mime}. With paths: {files: [...]}, one answer per entry in order, an entry that failed being {path, error}. A file over 10 MiB is a 500 naming its size.
write
Create or overwrite a file atomically, creating parent directories.
| Argument | Type | Required | Rule |
|---|---|---|---|
path | string | yes | |
content | string | no | The file as text. At most 10 MiB |
content_b64 | string | no | The file as base64, for binary content. At most 10 MiB decoded. Give content or content_b64, not both |
tree | string | no |
Result: {path, bytes, created}.
edit
Make exact string replacements, all or nothing across the whole call: if one edit does not apply, nothing is written.
| Argument | Type | Required | Rule |
|---|---|---|---|
path | string | no | One file. Use with edits |
edits | array | no | Objects {old, new, replace_all?}. old and new are strings, replace_all a boolean. old must occur exactly once unless replace_all is true |
files | array | no | 1 to 50 objects {path, edits}, for several files in one atomic call |
tree | string | no |
Result for one file: {path, applied}. With files: {files: [{path, applied}], applied}, where applied is the number of edits.
patch
Apply a unified diff, git, or plain -p1 format. The diff is checked first, so it is never applied in part. It takes fewer tokens than edit for a large rewrite.
| Argument | Type | Required | Rule |
|---|---|---|---|
diff | string | yes | |
cwd | string | no | The directory the diff's paths are relative to. Default the working directory root |
tree | string | no |
Result: {cwd, files}, the files it touched.
glob
List the files that match a glob under cwd, honoring gitignore, newest first.
| Argument | Type | Required | Rule |
|---|---|---|---|
pattern | string | yes | |
cwd | string | no | Default the working directory root |
tree | string | no |
Result: {cwd, paths, truncated}. At most 5000 paths; truncated says there were more.
grep
Search file contents with a regex under cwd, honoring gitignore. A file over 10 MiB is skipped.
| Argument | Type | Required | Rule |
|---|---|---|---|
pattern | string | yes | A regex |
cwd | string | no | Default the working directory root |
glob | string | no | Narrows the files searched |
mode | string | no | content (default), files, or count |
context | integer | no | Lines around each match, content mode. Default 0 |
ignore_case | boolean | no | Default false |
max | integer | no | Most results. Default and ceiling 2000 |
tree | string | no |
Result by mode: content is {cwd, matches: [{path, line, text, context}], truncated}, with context null when context is 0. files is {cwd, files, truncated}. count is {cwd, counts: [{path, count}], truncated}.
Commands and processes
exec
Run a command as the workspace user in the working directory. A string cmd runs as sh -c (not a login shell), an array runs as an argv. Without detach the command is a job: the call waits and answers. With detach: true it becomes a process and the call answers at once.
| Argument | Type | Required | Rule |
|---|---|---|---|
cmd | string or array of strings | yes | A shell string, or an argv. An empty array is 400 |
cwd | string | no | Default the working directory root |
env | object | no | Extra variables |
soft_ms | integer | no | How long to wait before the job is promoted. Default 25000, maximum 600000 |
head | integer | no | Keep only the first N lines of each stream |
tail | integer | no | Keep only the last N lines of each stream |
quiet | boolean | no | Answer the exit code alone, plus the last 20 lines of stderr on failure |
detach | boolean | no | Return {id} and keep running |
relaunch | boolean | no | With detach: start the process again after a workspace stop or restart. Default true; false lists it as killed_by_restart instead |
pty | boolean | no | Not supported in this version |
tree | string | no |
Result for a job that exits within the wait: {exit_code, stdout, stderr, truncated, timed_out, ms}. truncated is true when output passed the 4 MiB capture limit. A job still running at the wait is promoted to a process, never killed. The result is then {promoted: true, id, kind: "process", stdout_so_far, stderr_so_far, next, next_err, ms, hint}. Finish it with process_wait, and stop it with process_kill.
When head, tail, or quiet dropped lines, trimmed: true is added. Result for detach: {id}. At most 32 processes run at once. An exited process stays listed for 10 minutes.
curl -X POST -H "Authorization: Bearer $TOKEN" $TOOLS/tools/exec \ -d '{"cmd":"npm run dev","detach":true}'# {"id":"p1"}process_list
List every detached process.
| Argument | Type | Required | Rule |
|---|---|---|---|
tree | string | no |
Result: {processes: [{id, cmd, started_at, state, exit_code, failed}]}. state is running or exited. A process that a restart killed is {id, cmd, state: "killed_by_restart", restarted_at} for an hour. failed carries a note when the process did not start or did not start again, for example port 3000 in use.
process_output
Read a process's output since a byte offset. The server keeps the last 4 MiB of each stream.
| Argument | Type | Required | Rule |
|---|---|---|---|
id | string | yes | |
since | integer | no | Offset into stdout. Default 0, the start |
since_err | integer | no | Offset into stderr. Default 0 |
tree | string | no |
Result: {stdout, stderr, next, next_err, dropped, dropped_err, state, exit_code}. A killed process answers {state: "killed_by_restart", id, restarted_at, hint}. To page, pass back next and next_err. dropped is how much the ring had already discarded before since.
process_write
Write to a process's stdin. It fails after process_close_stdin or once the process has exited.
| Argument | Type | Required | Rule |
|---|---|---|---|
id | string | yes | |
data | string | no | Text. Give data or data_base64, not both |
data_base64 | string | no | Bytes, standard base64 |
tree | string | no |
Result: {bytes}.
process_close_stdin
Close a process's stdin, so a command that reads to the end of its input (cat, a while read loop) finishes. Closing it again is not an error.
| Argument | Type | Required | Rule |
|---|---|---|---|
id | string | yes | |
tree | string | no |
Result: {closed: true}.
process_wait
Wait until a process exits or the wait runs out, and read what it printed since an offset. While timed_out is true, call it again with since and since_err set to next and next_err. An id that ended more than 10 minutes ago, or never existed, answers state: "gone" and a hint, not an error.
| Argument | Type | Required | Rule |
|---|---|---|---|
id | string | yes | |
timeout_ms | integer | no | How long to wait for the exit. Default and maximum 20000 |
since | integer | no | The stdout offset to read from. Default 0 |
since_err | integer | no | The stderr offset to read from. Default 0 |
tree | string | no |
Result: {state, exit_code, timed_out, id, stdout, stderr, next, next_err, dropped, dropped_err, failed}, with hint while timed_out is true. A process that a restart killed answers state: "killed_by_restart", id, restarted_at, and a hint to run it again.
process_kill
Stop a process: TERM, then KILL after 5 seconds. The signal reaches the whole process group.
| Argument | Type | Required | Rule |
|---|---|---|---|
id | string | yes | |
signal | string | no | TERM (default) or KILL, which skips the wait |
tree | string | no |
Result: {state}.
Watches
At most 32 watches run at once.
watch
Start a watch and return its id. Give paths or cmd. With both, the server runs cmd after each batch of changes, one run at a time. The event then holds cmd_exit and output_tail, the last 2000 bytes of output. Editor temporary files raise no event.
| Argument | Type | Required | Rule |
|---|---|---|---|
paths | array of strings | no | Events are file system changes under these paths, recursively |
cmd | string or array of strings | no | Events are the output lines of this command |
pattern | string | no | A regex that filters the lines |
once | boolean | no | End the watch at the first event |
tree | string | no |
Result: {id}.
watch_poll
Get the events since an index. The server keeps the last 1000.
| Argument | Type | Required | Rule |
|---|---|---|---|
id | string | yes | |
since | integer | no | Index to read from. Default 0, the start |
Result: {events, next, dropped, state}. An event is {path, kind} for a path watch, {line} for a line of a command's output, or {exit} when the command ended. Pass next back as since.
watch_stop
Stop a watch, and its command if it has one.
| Argument | Type | Required | Rule |
|---|---|---|---|
id | string | yes |
Result: {state}.
Containers
Both run kd container in the working directory. The build runs in the workspace.
container_build
Build an image and push it to your registry.
| Argument | Type | Required | Rule |
|---|---|---|---|
tags | string or array of strings | yes | At least one, each name[:tag]. hello:1 is pushed as <registry>/<you>/hello:1, and team/hello:1 pushes under the team |
registry | string | no | The registry connector to push to. Optional when one connector is connected or you have one of your own |
context | string | no | A path in the working directory. Default . |
file | string | no | The Dockerfile, a path in the working directory |
build_args | string or array of strings | no | KEY=VALUE entries |
platform | string | no | |
no_cache | boolean | no | |
soft_ms | integer | no | How long to wait before the build is promoted to a process. Default 25000, maximum 600000 |
head | integer | no | Lines kept of each stream |
tail | integer | no | Lines kept of each stream. With neither head nor tail, each stream keeps its last 40 lines |
tree | string | no |
A tag that starts with a connected registry host is pushed as written. Result: {exit_code, stdout, stderr, truncated, ms}, with trimmed: true when lines were dropped. A build still running at soft_ms answers like a promoted exec: finish it with process_wait.
container_push
Copy an image the registry already holds to more names. It does not rebuild.
| Argument | Type | Required | Rule |
|---|---|---|---|
src | string | yes | For example hello:1 |
dst | string or array of strings | yes | At least one, for example [hello:latest] |
tree | string | no |
Result: the same shape as container_build.
Confinement
Each exec runs in a bwrap isolation. The command sees the home of its tree, read and write. It sees /nix, the userland of the image (/usr, /bin, /lib, and the like) and a few files of /etc read-only, its own /tmp, and the network of the workspace. It does not see /etc/kode, so a command cannot read the workspace token, and kd does not work from exec. Run kd from a shell of the workspace instead. If bwrap cannot start, the command runs without the isolation.
container_build and container_push run outside the capsule, with the credential that exec lacks. The caller chooses only what to build. The build context and the Dockerfile must be inside the tree.
A process that exits on its own is never started again.
A detached process that ran when the workspace stopped starts again when the workspace starts, with the same id, unless its exec passed relaunch: false. Its log then begins with a restart marker.
A script that starts with #!/usr/bin/env runs, because the image's /usr/bin is in the capsule. A process that fails to start 3 times in a row stays exited. One that a restart did not bring back is listed as killed_by_restart for an hour. Do not start the same server again after a restart: the first one already holds its port. A workspace restored or cloned from a snapshot relaunches no process of its source.
Tasks and journal
task
The caller's own task queue, one per tree, in .kd/tasks.json, which git ignores. Every action answers the whole queue: {running, queue, recent}, with the last five finished tasks in recent. One task runs at a time: start while another runs is an error that names it. A queue holds 200 tasks, a title 200 characters, and a note 2000.
| Argument | Type | Required | Meaning |
|---|---|---|---|
action | string | yes | add, start, done, fail, cancel, reorder, or list |
title | string | no | The title, for add |
note | string | no | A note, for add |
after | string | no | For add, the id of the task to queue after |
id | string | no | The task, for start, done, fail, and cancel |
result | string | no | For done |
reason | string | no | For fail |
ids | array of strings | no | For reorder, the queued ids in the new order |
tree | string | no |
Every call that changes files or runs a command adds one line to a journal in .kd/journal/ of the tree. A line holds the tool, a short summary, the outcome, the time taken, the paths touched, and the running task. The journal never stores the body of a write. It cuts a command to 500 characters, and replaces tokens and secret values with ***. It keeps 14 days and at most 64 MiB. The live page of the workspace in the console shows the queue and the journal, read only, to the owner.
Streams
Both stream routes need the same bearer header on the upgrade request, and send one JSON object per text frame. Add X-KD-Watch: 1 to a connection that only feeds a sidebar: it does not count as activity for the idle stop.
GET /stream/process/{id}?since=&since_err= replays the ring from the two offsets (default 0), then sends every frame as it arrives, then the exit, and closes. GET /stream/process/{id}/lines sends the same objects as NDJSON, application/x-ndjson, one per line, ending after the exit.
| Frame | Meaning |
|---|---|
{"stream": "stdout" or "stderr", "data": "…"} | Output, as lossy UTF-8 |
{"dropped": n, "dropped_err": n} | First frame, when an offset is behind what the ring still holds |
{"dropped_frames": n} | The reader fell behind and n frames were skipped |
{"exit": code} | The process ended. The last frame |
GET /stream/watch/{id}?since= replays events from an index, then follows. Its frames are the watch's events ({path, kind}, {line}, {exit}), {"dropped_events": n}, and {"state": "stopped"} when the watch was stopped. An unknown id is 404 with the text no process {id} or no watch {id}.