Status: unsupported experimental. These surfaces are source-available but outside the supported v3 product contract. They are hidden from default
hack --help(seehack help --all) and print a warning when invoked.
The gateway exposes a small, authenticated HTTP/WS surface for remote orchestration. It is designed for structured workflows (jobs + log streaming + shells) and keeps write access opt-in with explicit guardrails.
This is reference for the beta remote surface. Use Beta workflows for the guided path and Core docs for the default local product story.
⚠️ Experimental: remote exposure steps are still being validated end-to-end.
- The gateway binds to
127.0.0.1by default. Expose it only through a Zero Trust tunnel, VPN, or SSH port-forward. - Tokens are required for every request (HTTP and WS).
- Tokens have scopes (
readorwrite). Non-GET requests and shell streams requirewrite. - Non-GET requests are blocked unless global
controlPlane.gateway.allowWrites = true. - Token secrets are never stored; only hashed records are kept on disk.
- Audit log is appended to
~/.hack/daemon/gateway/audit.jsonl.
Recommended security posture:
- Use read-only tokens for monitoring.
- Use short-lived write tokens for remote execution.
- Put Cloudflare Access or Tailscale in front of the gateway.
- Rotate tokens; revoke unused ones.
hack remote setupChoose Cloudflare/Tailscale/SSH when prompted. The wizard enables the gateway for the current
project, optionally enables writes, restarts hackd, creates a token + QR payload, and can
configure + start the exposure helper you pick.
- Enable gateway and start hackd:
hack remote setup
# or:
hack gateway setup
# or manually:
hack gateway enable
hack daemon stop && hack daemon start- Create a token:
hack x gateway token-createToken management:
hack x gateway token-list
hack x gateway token-revoke <token-id>- (Optional) Allow writes + create a write token:
hack config set --global 'controlPlane.gateway.allowWrites' true
hack x gateway token-create --scope write- Expose the gateway (choose one):
- SSH:
ssh -L 7788:127.0.0.1:7788 <host> - Cloudflare Tunnel:
hack x cloudflare tunnel-setup --hostname gateway.example.com - Tailscale: use tailnet access (see Remote domains below)
- (Optional) Print a QR payload for remote clients:
hack remote qr --gateway-url https://gateway.example.com --token <token>
# or SSH payload:
hack remote qr --ssh --ssh-host <host> --ssh-user <user>You can also emit a QR immediately after setup (use --no-qr to skip):
hack gateway setupMonitor gateway activity locally:
hack remote monitorRecommended for exposing the gateway to mobile/web clients:
- Pick a hostname under a Cloudflare-managed zone (e.g.
gateway.dev.example.com). - Run
hack x cloudflare tunnel-setup --hostname gateway.dev.example.com. - Start the tunnel:
hack x cloudflare tunnel-start.
Cloudflare creates a CNAME to <tunnel-id>.cfargotunnel.com under your zone. The gateway URL is:
https://gateway.dev.example.com
Best for SSH from iOS clients (Terminus, Blink, etc):
- Join the tailnet on your laptop:
tailscale up --ssh - Enable MagicDNS in the Tailscale admin UI.
- Use the MagicDNS hostname in your SSH client (e.g.
laptop.tailnet.ts.net).
For gateway access over the tailnet, either:
- run
tailscale serve tcp 7788 127.0.0.1:7788, or - set global
controlPlane.gateway.bind = 0.0.0.0and restart hackd.
If you want a stable ssh.dev.example.com style hostname:
- Point the DNS record at your home IP and forward port 22 (not recommended), or
- Use Tailscale MagicDNS and treat
ssh.<tailnet>.ts.netas your stable host.
Cloudflare Access for SSH requires cloudflared access ssh on the client, which is not available
on iOS, so Tailscale is the easiest mobile SSH path.
HTTP requests:
Authorization: Bearer <token>- or
x-hack-token: <token>
WebSocket: Use the same header on the WS handshake. Bun supports:
const ws = new WebSocket("wss://gateway.example.com/control-plane/projects/..", {
headers: { Authorization: `Bearer ${token}` }
})Browser note: the native WebSocket API cannot set headers. For browser clients, pass the token as a query param on the WS URL:
wss://gateway.example.com/control-plane/projects/<id>/shells/<id>/stream?token=<token>
Only use this for WebSockets, and treat URLs as sensitive (tokens can leak via logs/history).
The remote client should:
- Check status
GET /v1/status
- Discover projects
GET /v1/projectsto getproject_id+ runtime status
- Run a job
POST /control-plane/projects/:projectId/jobs(write token + allowWrites)
- Stream logs/events
WS /control-plane/projects/:projectId/jobs/:jobId/stream
- Store results
- capture logs, exit status, and summary in your client/app
For interactive work, create a shell and stream over WS:
POST /control-plane/projects/:projectId/shellsWS /control-plane/projects/:projectId/shells/:shellId/stream
Clients should always persist:
jobId- last
logsOffset/eventsSeq(for resume)
CLI shell client (write token + allowWrites required):
hack x supervisor shell --gateway http://127.0.0.1:7788 --token $HACK_GATEWAY_TOKEN --project-id <id>TypeScript client (in-repo):
src/control-plane/sdk/gateway-client.tsexposescreateGatewayClientfor typed HTTP/WS calls.
Base URL: http://127.0.0.1:7788 (or your tunnel URL)
| Method | Path | Write required | Description |
|---|---|---|---|
| GET | /v1/status |
no | Daemon status + uptime |
| GET | /v1/node/status |
no | Node runtime/gateway/supervisor capability status |
| POST | /v1/node/workspaces/ensure |
yes | Resolve project workspace + ensure branch checkout |
| POST | /v1/node/devcontainers/up |
yes | Start devcontainer for resolved workspace |
| POST | /v1/node/devcontainers/down |
yes | Stop tracked devcontainer session |
| GET | /v1/node/devcontainers/:id |
no | Fetch devcontainer session metadata |
| GET | /v1/metrics |
no | Cache + stream metrics |
| GET | /v1/projects |
no | Gateway-enabled projects + runtime snapshot |
| GET | /v1/ps |
no | Compose project container list |
| GET | /v1/sessions |
no | List persistent mux-backed workspaces |
| POST | /v1/sessions |
yes | Create persistent mux-backed workspace |
| GET | /v1/sessions/:id |
no | Get workspace details |
| POST | /v1/sessions/:id/stop |
yes | Stop (kill) workspace |
| POST | /v1/sessions/:id/exec |
yes | Execute command in workspace |
| POST | /v1/sessions/:id/input |
yes | Send raw keystrokes |
| GET | /v1/env |
no | Env contract + resolution state (values redacted) |
| POST | /v1/env/set |
yes | Legacy compatibility write path for .hack/.env and older secret backends |
| POST | /v1/env/unset |
yes | Legacy compatibility unset path for .hack/.env and older secret backends |
| GET | /control-plane/projects/:projectId/jobs |
no | List jobs |
| POST | /control-plane/projects/:projectId/jobs |
yes | Create job |
| GET | /control-plane/projects/:projectId/jobs/:jobId |
no | Fetch job |
| POST | /control-plane/projects/:projectId/jobs/:jobId/cancel |
yes | Cancel job |
| WS | /control-plane/projects/:projectId/jobs/:jobId/stream |
no | Stream job logs/events |
| POST | /control-plane/projects/:projectId/shells |
yes | Create shell |
| GET | /control-plane/projects/:projectId/shells/:shellId |
no | Fetch shell |
| WS | /control-plane/projects/:projectId/shells/:shellId/stream |
yes | Stream shell PTY |
Sessions API: For detailed persistent workspace endpoint documentation, see Sessions.
Env API: For contract format and env endpoints, see Env & secrets.
Returns daemon status and uptime.
curl -H "Authorization: Bearer $HACK_GATEWAY_TOKEN" \
http://127.0.0.1:7788/v1/statusResponse:
| Field | Type | Description |
|---|---|---|
status |
string | Always ok on success |
version |
string | hackd version |
pid |
number | Process id |
started_at |
string | ISO timestamp |
uptime_ms |
number | Uptime in milliseconds |
Returns node capability metadata for controller routing:
- host identity (
node.name,node.platform,node.arch, Bun version) - gateway settings and enabled projects
- supervisor settings
- active devcontainer sessions
This endpoint is used by hack node status and hack dispatch run during node selection.
Ensures a target workspace exists on the node and optionally checks out/creates a branch.
Request body selectors:
| Field | Type | Required | Description |
|---|---|---|---|
project |
string | no | Project name selector |
project_id |
string | no | Node-local project id selector |
path |
string | no | Absolute path selector |
branch |
string | no | Branch to ensure/check out |
bootstrap |
object | no | Optional clone/register hints when workspace is missing |
bootstrap fields:
| Field | Type | Required | Description |
|---|---|---|---|
repo_url |
string | yes | Git clone URL used for fresh-node bootstrap |
project_name |
string | no | Preferred project name when registering clone |
project_root |
string | no | Absolute destination path override on node |
Response:
{
"workspace": {
"projectId": "4132b9154775",
"projectName": "hack-cli",
"projectRoot": "/Users/local-user/dev/hack-dance/hack-cli",
"projectDir": "/Users/local-user/dev/hack-dance/hack-cli/.hack",
"branch": "feature/my-branch"
}
}POST /v1/node/devcontainers/upPOST /v1/node/devcontainers/downGET /v1/node/devcontainers/:id
These endpoints manage tracked devcontainer sessions and are consumed by node/devcontainer bridge workflows.
Returns daemon cache and stream metrics.
Response:
| Field | Type | Description |
|---|---|---|
status |
string | Always ok on success |
started_at |
string | ISO timestamp |
uptime_ms |
number | Uptime in milliseconds |
cache_updated_at |
string or null | Last cache refresh time |
cache_age_ms |
number or null | Cache age in milliseconds |
last_refresh_at |
string or null | Last refresh attempt |
refresh_count |
number | Refresh count |
refresh_failures |
number | Refresh failures |
last_event_at |
string or null | Last docker event timestamp |
events_seen |
number | Docker events seen |
streams_active |
number | Active WS streams |
runtime_ok |
boolean | Docker runtime availability |
runtime_error |
string or null | Runtime error details (when unavailable) |
runtime_checked_at |
string or null | Last runtime check timestamp |
runtime_last_ok_at |
string or null | Last successful runtime check |
runtime_reset_at |
string or null | Last detected runtime reset |
runtime_reset_count |
number | Runtime reset counter |
Returns gateway-enabled registered projects + runtime status. Includes project_id.
Query parameters:
| Name | Type | Required | Description |
|---|---|---|---|
filter |
string | no | Project name filter |
include_global |
boolean | no | Include global infra entries |
include_unregistered |
boolean | no | Ignored over the gateway (always false) |
include_meta |
boolean | no | Include git/worktree/session/env metadata (opt-in; only returned for enabled projects) |
curl -H "Authorization: Bearer $HACK_GATEWAY_TOKEN" \
"http://127.0.0.1:7788/v1/projects?include_global=true"Response:
| Field | Type | Description |
|---|---|---|
generated_at |
string | ISO timestamp |
filter |
string or null | Applied filter |
include_global |
boolean | Include global infra entries |
include_unregistered |
boolean | Always false over the gateway |
runtime_ok |
boolean | Docker runtime availability |
runtime_error |
string or null | Runtime error details (when unavailable) |
runtime_checked_at |
string or null | Last runtime check timestamp |
runtime_last_ok_at |
string or null | Last successful runtime check |
runtime_reset_at |
string or null | Last detected runtime reset |
runtime_reset_count |
number | Runtime reset counter |
projects |
ProjectView[] | Gateway-enabled projects only |
Fetch runtime container status for a compose project.
Query parameters:
| Name | Type | Required | Description |
|---|---|---|---|
compose_project |
string | yes | Compose project id |
project |
string | no | Display name |
branch |
string | no | Branch name |
Response:
| Field | Type | Description |
|---|---|---|
project |
string | Display project name |
branch |
string or null | Branch name |
composeProject |
string | Compose project id |
runtime_ok |
boolean | Docker runtime availability |
runtime_error |
string or null | Runtime error details (when unavailable) |
runtime_checked_at |
string or null | Last runtime check timestamp |
runtime_last_ok_at |
string or null | Last successful runtime check |
runtime_reset_at |
string or null | Last detected runtime reset |
runtime_reset_count |
number | Runtime reset counter |
items |
PsItem[] | docker compose ps style rows |
Create a job (requires write token + allowWrites).
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
runner |
string | no | Runner name (default: generic) |
command |
string[] | yes | Command argv |
cwd |
string | no | Working directory (relative to project root) |
env |
object | no | Environment overrides |
{
"runner": "generic",
"command": ["bash", "-lc", "bun test"],
"cwd": ".",
"env": { "NODE_ENV": "test" }
}Response:
| Field | Type | Description |
|---|---|---|
job |
JobMeta | Job metadata |
Read and manage job metadata.
Responses:
| Endpoint | Field | Type | Description |
|---|---|---|---|
GET /jobs |
jobs |
JobMeta[] | Job list |
GET /jobs/:jobId |
job |
JobMeta | Job details |
POST /jobs/:jobId/cancel |
status |
string | Always cancelled on success |
Client -> server (JSON):
| Type | Fields | Description |
|---|---|---|
hello |
logsFrom?, eventsFrom? |
Start streaming from offsets (default 0) |
{ "type": "hello", "logsFrom": 0, "eventsFrom": 0 }Server -> client (JSON):
| Type | Fields | Description |
|---|---|---|
ready |
logsOffset, eventsSeq |
Current offsets |
log |
stream, offset, data |
Log chunk (stream is combined) |
event |
seq, event |
Job event (see JobEvent) |
heartbeat |
ts, logsOffset, eventsSeq |
Keepalive with offsets |
error |
message |
Error string (e.g. job_not_found) |
{ "type": "ready", "logsOffset": 0, "eventsSeq": 0 }
{ "type": "log", "stream": "combined", "offset": 128, "data": "..." }
{ "type": "event", "seq": 2, "event": { "type": "job.started" } }
{ "type": "heartbeat", "ts": "...", "logsOffset": 128, "eventsSeq": 2 }Create a PTY-backed shell (requires write token + allowWrites).
Request body:
| Field | Type | Required | Description |
|---|---|---|---|
cols |
number | no | Terminal columns |
rows |
number | no | Terminal rows |
cwd |
string | no | Working directory (relative to project root) |
shell |
string | no | Shell path (default: $SHELL or /bin/bash) |
env |
object | no | Environment overrides |
{ "cols": 120, "rows": 30, "cwd": ".", "shell": "/bin/zsh" }Response:
| Field | Type | Description |
|---|---|---|
shell |
ShellMeta | Shell metadata |
Fetch shell metadata.
Response:
| Field | Type | Description |
|---|---|---|
shell |
ShellMeta | Shell metadata |
Client -> server (JSON):
| Type | Fields | Description |
|---|---|---|
hello |
cols?, rows? |
Optional initial size |
input |
data |
Input text |
resize |
cols, rows |
Resize PTY |
signal |
signal |
Send signal (SIGINT, SIGTERM, SIGKILL, SIGHUP, SIGQUIT, SIGUSR1, SIGUSR2, SIGTSTP) |
close |
- | Close the shell |
{ "type": "hello", "cols": 120, "rows": 30 }
{ "type": "input", "data": "ls -la\n" }
{ "type": "resize", "cols": 160, "rows": 40 }
{ "type": "signal", "signal": "SIGINT" }
{ "type": "close" }Server -> client (JSON):
| Type | Fields | Description |
|---|---|---|
ready |
shellId, cols, rows, cwd, shell, status |
Shell info |
output |
data |
Output data |
exit |
exitCode, signal |
Exit info |
{ "type": "ready", "shellId": "...", "cols": 120, "rows": 30, "cwd": "...", "shell": "/bin/bash", "status": "running" }
{ "type": "output", "data": "..." }
{ "type": "exit", "exitCode": 0, "signal": null }Non-JSON text frames are treated as raw input.
| Field | Type | Description |
|---|---|---|
jobId |
string | Job id |
status |
string | queued, starting, running, completed, failed, cancelled, awaiting_input |
runner |
string | Runner name |
command |
string[] (optional) | Command argv |
projectId |
string (optional) | Project id |
projectName |
string (optional) | Project name |
createdAt |
string | ISO timestamp |
updatedAt |
string | ISO timestamp |
lastEventSeq |
number | Last event sequence number |
| Field | Type | Description |
|---|---|---|
seq |
number | Event sequence number |
ts |
string | ISO timestamp |
type |
string | Event type (e.g. job.started) |
payload |
object (optional) | Optional payload |
| Field | Type | Description |
|---|---|---|
shellId |
string | Shell id |
status |
string | running or exited |
createdAt |
string | ISO timestamp |
updatedAt |
string | ISO timestamp |
projectId |
string (optional) | Project id |
projectName |
string (optional) | Project name |
cwd |
string | Working directory |
shell |
string | Shell path |
cols |
number | Terminal columns |
rows |
number | Terminal rows |
pid |
number (optional) | Process id |
exitCode |
number (optional) | Exit code |
signal |
string or null | Exit signal |
| Field | Type | Description |
|---|---|---|
project_id |
string or null | Project id |
name |
string | Project name |
dev_host |
string or null | Dev host (without scheme) |
repo_root |
string or null | Repo root path |
project_dir |
string or null | Project .hack dir |
defined_services |
string[] or null | Services defined in compose |
runtime |
RuntimeProject or null | Runtime snapshot |
branch_runtime |
array | Branch entries { branch, runtime } |
kind |
string | registered or unregistered |
status |
string | running, stopped, missing, unregistered, unknown |
| Field | Type | Description |
|---|---|---|
project |
string | Compose project name |
working_dir |
string or null | Compose working directory |
services |
RuntimeService[] | Services + containers |
| Field | Type | Description |
|---|---|---|
service |
string | Service name |
containers |
RuntimeContainer[] | Containers |
| Field | Type | Description |
|---|---|---|
id |
string | Container id |
state |
string | Container state |
status |
string | Human-readable status |
name |
string | Container name |
ports |
string | Port mapping string |
image |
string or null | Container image |
ip |
string or null | Container IP address |
mounts |
RuntimeMount[] | Volume/bind mounts |
labels |
object | Container labels |
working_dir |
string or null | Compose working directory |
| Field | Type | Description |
|---|---|---|
Service |
string | Compose service name |
Name |
string | Container name |
Status |
string | Container status |
Ports |
string | Ports string |
401missing_tokenorinvalid_token403writes_disabled,write_scope_required, orproject_disabled404not_found426upgrade_required
See examples/basic/gateway-demo.ts for a runnable script that:
- checks status
- creates a job
- streams logs/events over WS
Browser shell demo:
examples/next-app/app/gateway/page.tsx(xterm UI)
Run it with:
export HACK_GATEWAY_URL="http://127.0.0.1:7788"
export HACK_GATEWAY_TOKEN="..."
export HACK_PROJECT_ID="..."
export HACK_COMMAND="echo hello"
export HACK_ALLOW_WRITES="1"
bun run examples/basic/gateway-demo.tsRun the gateway e2e (auto-generates tokens; requires gateway enabled and hackd running):
# Optional: run against a specific project directory
export HACK_GATEWAY_E2E_CWD=/path/to/project
# Optional: disable write tests
export HACK_GATEWAY_E2E_WRITE=0
bun run test:e2e:gateway