Skip to content
DocsAll docs
Console

Tool server API

Lists the tools and the routes of the tool server of a workspace.

Last updated

On this page

Reaching it

The address comes from GET /v1/workspaces/{id}/tools, which answers the workspace's owner alone:

json
{ "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 token field 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 a 401 instead 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:

CLI
export TOKEN=<the workspace token>export TOOLS=http://localhost:7788

A 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.

CLI
curl -H "Authorization: Bearer $TOKEN" $TOOLS/tools
json
{  "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.

CLI
curl -X POST -H "Authorization: Bearer $TOKEN" $TOOLS/tools/exec -d '{"cmd":"ls"}'
json
{ "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": "…"}:

StatusMeaning
400A 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
401No or wrong token
403A path that resolves outside the working directory
404An unknown tool
413A body over about 14 MiB, the file limit in base64 plus room for the rest of the JSON
500The 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

RouteAnswer
GET /healthz{"ok": true, "root": "<workspace>", "trees": ["main"]} with no token. trees lists the working directories served so far, main first
POST /hold204. 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}/linesThe 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.

ArgumentTypeRequiredRule
pathstringnoOne file. Give path or paths
pathsarrayno1 to 50 entries, each a path string or {path, offset, limit}. Each file gets its own answer
offsetintegernoFirst line, 1-based. Default 1
limitintegernoLines to return. Default 2000
treestringno

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.

ArgumentTypeRequiredRule
pathstringyes
contentstringnoThe file as text. At most 10 MiB
content_b64stringnoThe file as base64, for binary content. At most 10 MiB decoded. Give content or content_b64, not both
treestringno

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.

ArgumentTypeRequiredRule
pathstringnoOne file. Use with edits
editsarraynoObjects {old, new, replace_all?}. old and new are strings, replace_all a boolean. old must occur exactly once unless replace_all is true
filesarrayno1 to 50 objects {path, edits}, for several files in one atomic call
treestringno

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.

ArgumentTypeRequiredRule
diffstringyes
cwdstringnoThe directory the diff's paths are relative to. Default the working directory root
treestringno

Result: {cwd, files}, the files it touched.

glob

List the files that match a glob under cwd, honoring gitignore, newest first.

ArgumentTypeRequiredRule
patternstringyes
cwdstringnoDefault the working directory root
treestringno

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.

ArgumentTypeRequiredRule
patternstringyesA regex
cwdstringnoDefault the working directory root
globstringnoNarrows the files searched
modestringnocontent (default), files, or count
contextintegernoLines around each match, content mode. Default 0
ignore_casebooleannoDefault false
maxintegernoMost results. Default and ceiling 2000
treestringno

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.

ArgumentTypeRequiredRule
cmdstring or array of stringsyesA shell string, or an argv. An empty array is 400
cwdstringnoDefault the working directory root
envobjectnoExtra variables
soft_msintegernoHow long to wait before the job is promoted. Default 25000, maximum 600000
headintegernoKeep only the first N lines of each stream
tailintegernoKeep only the last N lines of each stream
quietbooleannoAnswer the exit code alone, plus the last 20 lines of stderr on failure
detachbooleannoReturn {id} and keep running
relaunchbooleannoWith detach: start the process again after a workspace stop or restart. Default true; false lists it as killed_by_restart instead
ptybooleannoNot supported in this version
treestringno

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.

CLI
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.

ArgumentTypeRequiredRule
treestringno

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.

ArgumentTypeRequiredRule
idstringyes
sinceintegernoOffset into stdout. Default 0, the start
since_errintegernoOffset into stderr. Default 0
treestringno

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.

ArgumentTypeRequiredRule
idstringyes
datastringnoText. Give data or data_base64, not both
data_base64stringnoBytes, standard base64
treestringno

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.

ArgumentTypeRequiredRule
idstringyes
treestringno

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.

ArgumentTypeRequiredRule
idstringyes
timeout_msintegernoHow long to wait for the exit. Default and maximum 20000
sinceintegernoThe stdout offset to read from. Default 0
since_errintegernoThe stderr offset to read from. Default 0
treestringno

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.

ArgumentTypeRequiredRule
idstringyes
signalstringnoTERM (default) or KILL, which skips the wait
treestringno

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.

ArgumentTypeRequiredRule
pathsarray of stringsnoEvents are file system changes under these paths, recursively
cmdstring or array of stringsnoEvents are the output lines of this command
patternstringnoA regex that filters the lines
oncebooleannoEnd the watch at the first event
treestringno

Result: {id}.

watch_poll

Get the events since an index. The server keeps the last 1000.

ArgumentTypeRequiredRule
idstringyes
sinceintegernoIndex 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.

ArgumentTypeRequiredRule
idstringyes

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.

ArgumentTypeRequiredRule
tagsstring or array of stringsyesAt least one, each name[:tag]. hello:1 is pushed as <registry>/<you>/hello:1, and team/hello:1 pushes under the team
registrystringnoThe registry connector to push to. Optional when one connector is connected or you have one of your own
contextstringnoA path in the working directory. Default .
filestringnoThe Dockerfile, a path in the working directory
build_argsstring or array of stringsnoKEY=VALUE entries
platformstringno
no_cachebooleanno
soft_msintegernoHow long to wait before the build is promoted to a process. Default 25000, maximum 600000
headintegernoLines kept of each stream
tailintegernoLines kept of each stream. With neither head nor tail, each stream keeps its last 40 lines
treestringno

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.

ArgumentTypeRequiredRule
srcstringyesFor example hello:1
dststring or array of stringsyesAt least one, for example [hello:latest]
treestringno

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.

ArgumentTypeRequiredMeaning
actionstringyesadd, start, done, fail, cancel, reorder, or list
titlestringnoThe title, for add
notestringnoA note, for add
afterstringnoFor add, the id of the task to queue after
idstringnoThe task, for start, done, fail, and cancel
resultstringnoFor done
reasonstringnoFor fail
idsarray of stringsnoFor reorder, the queued ids in the new order
treestringno

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.

FrameMeaning
{"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}.