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
- 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 session and through the ssh tunnel. From a laptop, kloudlite 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/kloudlite/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 sandbox 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": { "sandbox": "active", "network": "open" }}fence.sandbox is active when exec runs inside the sandbox 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 11 MiB, the file limit 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 |
/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. A 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 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 | yes | At most 10 MiB |
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 environment variables |
timeout_ms | integer | no | How long to wait. Default 120000, maximum 600000 |
timeout | number | no | The same wait in seconds. Give this or timeout_ms |
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 |
pty | boolean | no | Not supported in this version |
tree | string | no |
Result for a job: {exit_code, stdout, stderr, truncated, timed_out, ms}. truncated is true when output passed the 4 MiB capture limit. At the timeout, exit_code is -1, timed_out is true, and the process group is killed.
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. 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}. 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. Once it answers exited, everything the process printed is readable with process_output.
| Argument | Type | Required | Rule |
|---|---|---|---|
id | string | yes | |
timeout_ms | integer | no | Default and maximum 20000 |
timeout | number | no | The same wait in seconds |
tree | string | no |
Result: {state, exit_code, timed_out}.
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.
| 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 kl container in the working directory. The build goes to your owner's hidden builder.
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 |
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 | |
timeout_ms | integer | no | Default and maximum 600000 |
timeout | number | no | The same wait in seconds |
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 |
Result: {exit_code, stdout, stderr, truncated, timed_out, ms}, with trimmed: true when lines were dropped.
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.
Streams
Both stream routes need the same bearer header on the upgrade request, and send one JSON object per text frame. Add X-KL-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}.