Terminal: File Drag-and-Drop
Section titled “Terminal: File Drag-and-Drop”These endpoints stage files into a terminal session and inject them as a drag-and-drop frame. There are two ways to deliver a drop: a single one-shot request that stages and commits inline, and a three-step staged transaction that streams large files slice-by-slice before commit. Both paths end at the same final action: writing an OSC 8472 escape frame into the terminal’s PTY input. A drop only becomes visible to the program running in the terminal at the commit step (or at the end of the one-shot call). An uncommitted staged transaction holds bytes on the server but changes nothing on screen.
Choosing an approach
Section titled “Choosing an approach”| Use the one-shot drop when… | Use the staged transaction when… |
|---|---|
| The file is small enough to base64-encode into a single JSON request body | The file is large or you want to stream it in slices |
| You want the simplest call shape (one request, one response) | You need to coordinate multiple files dropped together |
| Latency of a single round-trip matters more than throughput | You want the server to tell you its effective max_slice cap before you start uploading |
One-shot drop
Section titled “One-shot drop”POST /api/v1/terminal/drop runs the begin, upload, and commit code paths in one request. Items are encoded as {name, b64} for files or {name, dir: true, items: [...]} for nested directories, with file contents base64-encoded in-line. Use this for small drops where the JSON body comfortably fits one HTTP request.
POST /api/v1/terminal/drop
Section titled “POST /api/v1/terminal/drop”Begin, stage, and commit a drop in a single request through the same server code paths as the staged sequence. Intended for small drops and end-to-end tests. PTY sessions only. Never blocks.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
terminal_id | query | string | Yes | Terminal session ID (numeric 1-65535). On calls routed through a terminal-N containers-proxy hostname, the proxy sets this from the hostname label and overwrites any value you send — pick the terminal via the hostname; supply it directly only when calling the terminal service without the proxy |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
ctx | string | Yes | Drop context: "drop" or "paste" |
r | integer | No | Drop cell row |
c | integer | No | Drop cell column |
items | array | Yes | File/dir items ([{name, b64}] or [{name, dir: true, items: [...]}]) |
{ "ctx": "drop", "r": 12, "c": 4, "items": [ { "name": "report.pdf", "b64": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9MZW5ndGggMjE0Pj5zdHJlYW0KQlQKL1YxIDIgMCBSPj4+CmVuZG9iago=" }, { "name": "attachments", "dir": true, "items": [ { "name": "notes.md", "b64": "IyBNb250aGx5IFJlcG9ydAoKVGhpcyBpcyBhIHNhbXBsZSByZXBvcnQuCg==" } ] } ]}Response
Drop sealed and frame injected.
{ "status": "ok", "drop_id": "A1b2C3d4E5f6G7h8I9j0Kl", "mh": "<sha256hex>", "n": 2}Invalid JSON or parameters.
{ "error": "Missing 'items' array"}Session not found.
{ "error": "Session not found"}Non-PTY session, staging unavailable, or verification failure.
{ "error": "Commit target is not a PTY session"}A staging cap was exceeded.
{ "error": "Staging cap exceeded"}SDK Usage
curl -X POST "https://{projectId}-{containerId}-terminal-1.{server}.containers.hoody.com/api/v1/terminal/drop?terminal_id=1" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "ctx": "drop", "r": 12, "c": 4, "items": [ { "name": "report.pdf", "b64": "JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9MZW5ndGggMjE0Pj5zdHJlYW0KQlQKL1YxIDIgMCBSPj4+CmVuZG9iago=" } ] }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-terminal-1.{server}.containers.hoody.com', token: process.env.HOODY_TOKEN });
await client.terminal.drops.send({ ctx: 'drop', r: 12, c: 4, items: [ { name: 'report.pdf', b64: 'JVBERi0xLjQKJeLjz9MKMyAwIG9iago8PC9MZW5ndGggMjE0Pj5zdHJlYW0KQlQKL1YxIDIgMCBSPj4+CmVuZG9iago=', }, ],}, { terminal_id: '1' });Staged drop transaction
Section titled “Staged drop transaction”For a large file or several files dropped together, run the three-step sequence in order. Each step carries the same drop handle and token returned by the begin call.
- Begin opens a staging transaction and returns the drop handle plus
max_slice, the server’s effective per-slice body cap. - Upload appends one raw slice of file bytes at a given offset. Call it repeatedly to stream a large file.
- Commit finalizes the transaction: verifies the manifest against the staged bytes, writes
manifest.json, seals the drop, and injects one OSC 8472 frame into the terminal.
Bytes accumulated during the upload phase are invisible to the program in the terminal until the commit step runs. A duplicate commit of an already-sealed drop with a valid token re-injects the same frame and returns 200.
Step 1: Begin a transaction
Section titled “Step 1: Begin a transaction”POST /api/v1/terminal/drop-begin
Section titled “POST /api/v1/terminal/drop-begin”Mints a server-side drop id and a high-entropy drop token, and creates the staging directory for the terminal. The client uploads file slices via /upload (authenticated by the token) and finalizes via /drop-commit. max_slice echoes the server’s effective request-body cap so the client never triggers a 413 mid-drop. Returns 409 when the terminal session has no exported staging base (for example, staging disabled or a fail-closed spawn).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
terminal_id | query | string | Yes | Terminal session ID (numeric 1-65535). On calls routed through a terminal-N containers-proxy hostname, the proxy sets this from the hostname label and overwrites any value you send — pick the terminal via the hostname; supply it directly only when calling the terminal service without the proxy |
This endpoint accepts no request body.
Response
Drop transaction opened. The body returns the drop id, drop token, and max_slice cap.
{ "drop_id": "A1b2C3d4E5f6G7h8I9j0Kl", "drop_token": "...", "max_slice": 8388608}Missing terminal_id.
{ "error": "terminal_id parameter required"}Request method is not POST. Emits method_not_allowed with an Allow: POST header.
{ "error": "method_not_allowed", "allow": "POST"}The terminal has no exported staging base.
{ "error": "Drag-and-drop staging is not available for this terminal"}SDK Usage
curl -X POST "https://{projectId}-{containerId}-terminal-1.{server}.containers.hoody.com/api/v1/terminal/drop-begin?terminal_id=1" \ -H "Authorization: Bearer $HOODY_TOKEN"import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-terminal-1.{server}.containers.hoody.com', token: process.env.HOODY_TOKEN });
const begin = await client.terminal.drops.create({ terminal_id: '1',});
const dropHandle = begin.data.drop_id;const dropToken = begin.data.drop_token;const maxSlice = begin.data.max_slice;Step 2: Upload a slice
Section titled “Step 2: Upload a slice”POST /api/v1/terminal/upload
Section titled “POST /api/v1/terminal/upload”Appends the raw request body to the staged file at path starting at byte offset. The body is NOT JSON — it is the raw slice bytes, at most max_slice bytes as returned by drop-begin. offset MUST equal the file’s current staged size, else the request returns 409 with the current size in the JSON error body so the client can resume. Missing or wrong token, an unknown drop, or a sealed drop return 409. Byte and count caps return 413.
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
terminal_id | query | string | Yes | Terminal session ID (numeric 1-65535). On calls routed through a terminal-N containers-proxy hostname, the proxy sets this from the hostname label and overwrites any value you send — pick the terminal via the hostname; supply it directly only when calling the terminal service without the proxy |
drop | query | string | Yes | Drop id from /drop-begin |
token | query | string | Yes | Drop token from /drop-begin |
path | query | string | Yes | Sanitized relative path of the staged file (no .., not absolute) |
offset | query | integer | Yes | Byte offset to write at (must equal the current staged size) |
Request Body
Raw file-slice bytes (NOT JSON) sent as application/octet-stream, at most max_slice bytes as returned by drop-begin.
Response
Slice staged.
{ "status": "ok", "bytes_written": 4096, "offset": 8192}Missing or invalid parameters or path.
{ "error": "Invalid staged path"}Request method is not POST.
{ "error": "method_not_allowed", "allow": "POST"}Offset mismatch (the JSON error body carries the current staged size), bad token, sealed drop, or unknown drop.
{ "error": "Offset mismatch", "current_size": 8192}A staging byte/count cap was exceeded, or the body exceeded the server’s body size limit.
{ "error": "Staging cap exceeded"}SDK Usage
curl -X POST "https://{projectId}-{containerId}-terminal-1.{server}.containers.hoody.com/api/v1/terminal/upload?terminal_id=1&drop=drp_a1b2c3d4e5f67890&token=tkn_zXk7pQ2vN9mL4jR8sY5wA1b2C3&path=large.bin&offset=0" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/octet-stream" \ --data-binary @slice-001.binimport { HoodyClient } from 'hoody-sdk';import * as fs from 'node:fs';
const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-terminal-1.{server}.containers.hoody.com', token: process.env.HOODY_TOKEN });
const sliceBytes: Buffer = fs.readFileSync('slice-001.bin');
await client.terminal.drops.writeChunk(sliceBytes, { terminal_id: '1', drop: 'drp_a1b2c3d4e5f67890', token: 'tkn_zXk7pQ2vN9mL4jR8sY5wA1b2C3', path: 'large.bin', offset: 0,});Step 3: Commit and inject
Section titled “Step 3: Commit and inject”POST /api/v1/terminal/drop-commit
Section titled “POST /api/v1/terminal/drop-commit”Verifies the JSON manifest DRAFT (items) against the staged bytes — existence, size, and sha256 h when present; zero-size items are created; d: 1 entries are empty directories — writes the canonical manifest.json, SEALS the drop, and injects one OSC 8472 frame into the terminal’s PTY input carrying the base64url staging root and the manifest sha256. PTY sessions only (otherwise 409). A duplicate commit of an already-sealed drop with a valid token re-injects the same frame and returns 200 (idempotent retry rescue).
Parameters
| Name | In | Type | Required | Description |
|---|---|---|---|---|
terminal_id | query | string | Yes | Terminal session ID (numeric 1-65535). On calls routed through a terminal-N containers-proxy hostname, the proxy sets this from the hostname label and overwrites any value you send — pick the terminal via the hostname; supply it directly only when calling the terminal service without the proxy |
drop | query | string | Yes | Drop id from /drop-begin |
token | query | string | Yes | Drop token from /drop-begin |
Request Body
| Field | Type | Required | Description |
|---|---|---|---|
ctx | string | Yes | Drop context: "drop" or "paste" |
r | integer | No | Drop cell row (Chat grid pane mapping) |
c | integer | No | Drop cell column |
cr | string | No | Clip-read correlation nonce ([A-Za-z0-9_-]{1,64}); echoed verbatim as the injected frame’s cr field so the TUI can match a clipboard-read landing. Invalid or oversized values are ignored |
items | array | Yes | Manifest entries [{p, d, s, name, h?}] |
{ "ctx": "drop", "r": 12, "c": 4, "cr": "clip-abc123", "items": [ { "p": "large.bin", "d": 0, "s": 1048576, "name": "large.bin" } ]}Response
Drop sealed and frame injected.
{ "status": "ok", "drop_id": "A1b2C3d4E5f6G7h8I9j0Kl", "mh": "<sha256hex>", "n": 3}Invalid JSON or missing parameters.
{ "error": "Invalid JSON"}Session not found.
{ "error": "Session not found"}Request method is not POST.
{ "error": "method_not_allowed", "allow": "POST"}Manifest mismatch, bad token, unknown drop, or non-PTY session.
{ "error": "Manifest draft does not match staged bytes"}The manifest or item count exceeded a cap.
{ "error": "Staging cap exceeded"}Frame injection or filesystem failure.
{ "error": "Failed to inject drop frame"}SDK Usage
curl -X POST "https://{projectId}-{containerId}-terminal-1.{server}.containers.hoody.com/api/v1/terminal/drop-commit?terminal_id=1&drop=drp_a1b2c3d4e5f67890&token=tkn_zXk7pQ2vN9mL4jR8sY5wA1b2C3" \ -H "Authorization: Bearer $HOODY_TOKEN" \ -H "Content-Type: application/json" \ -d '{ "ctx": "drop", "r": 12, "c": 4, "cr": "clip-abc123", "items": [ { "p": "large.bin", "d": 0, "s": 1048576, "name": "large.bin" } ] }'import { HoodyClient } from 'hoody-sdk';
const client = new HoodyClient({ baseURL: 'https://{projectId}-{containerId}-terminal-1.{server}.containers.hoody.com', token: process.env.HOODY_TOKEN });
await client.terminal.drops.commit({ ctx: 'drop', r: 12, c: 4, cr: 'clip-abc123', items: [ { p: 'large.bin', d: 0, s: 1048576, name: 'large.bin' }, ],}, { terminal_id: '1', drop: 'drp_a1b2c3d4e5f67890', token: 'tkn_zXk7pQ2vN9mL4jR8sY5wA1b2C3',});End-to-end example
Section titled “End-to-end example”The three staged calls share the same drop handle and token returned by drop-begin. The slice upload in step 2 is repeated once per slice; the example below shows a single 1 MiB slice for clarity.
HOST="https://{projectId}-{containerId}-terminal-1.{server}.containers.hoody.com"AUTH="Authorization: Bearer $HOODY_TOKEN"
# 1. BeginBEGIN=$(curl -s -X POST "$HOST/api/v1/terminal/drop-begin?terminal_id=1" -H "$AUTH")DROP=$(echo "$BEGIN" | jq -r '.drop_id')TOKEN=$(echo "$BEGIN" | jq -r '.drop_token')MAX_SLICE=$(echo "$BEGIN" | jq -r '.max_slice')
# 2. Upload one slicecurl -X POST "$HOST/api/v1/terminal/upload?terminal_id=1&drop=$DROP&token=$TOKEN&path=large.bin&offset=0" \ -H "$AUTH" \ -H "Content-Type: application/octet-stream" \ --data-binary @slice-001.bin
# 3. Commitcurl -X POST "$HOST/api/v1/terminal/drop-commit?terminal_id=1&drop=$DROP&token=$TOKEN" \ -H "$AUTH" \ -H "Content-Type: application/json" \ -d '{ "ctx": "drop", "r": 12, "c": 4, "items": [ { "p": "large.bin", "d": 0, "s": 1048576, "name": "large.bin" } ] }'For a multi-slice file, repeat step 2 with offset set to the cumulative byte count already written. The 409 error body on an offset mismatch reports the current staged size so you can resume without re-uploading from the beginning.