tinypaw is a queue-backed agent harness with a Node.js CLI client, an HTTPS/SSE gateway, Redis, BullMQ, and the OpenAI Responses API. It can use either the hosted OpenAI API or a local OpenAI-compatible server such as LM Studio.
- One-shot and interactive CLI sessions.
- Asynchronous turn processing through BullMQ.
- Session events, leases, skills, configuration, and usage limits in Redis.
- Multi-step Responses API loop with custom function tools.
- Incremental model output from typed Responses API streaming events.
- SSE and persistent WebSocket provider transports.
- Hosted OpenAI and local loopback endpoints.
- Automatic local model discovery through
GET /v1/models. - Authenticated HTTPS turn submission with SSE lifecycle/result streaming.
- OpenAI-compatible
/v1/modelsand/v1/responsesendpoints for external clients, including typed Responses API streaming. - Static checks, unit tests, and Redis integration tests.
CLI/API client <── HTTPS/SSE ──> gateway
├── session/skill APIs ──> Redis 8
└── enqueue ──> BullMQ ──> worker
├──> Redis 8
└──> Responses API
(SSE/WebSocket)
The worker currently exposes two tools to the model:
load_skillloads a named skill from Redis.load_configloads a JSON configuration namespace from Redis.
The CLI is a gateway-only client. It does not receive Redis credentials, use BullMQ, or connect to the model provider. The gateway owns queue submission and session/skill APIs; only the worker calls the model.
- Node.js 20 or newer.
- Docker with Compose, or an existing Redis 8 server.
- A TLS certificate and private key for the gateway; OpenSSL is optional for generating the documented development certificate.
- One of:
- an OpenAI API key;
- a local server implementing
/v1/modelsand streaming/v1/responsesover SSE or WebSocket.
npm install
cp .env.example .env
docker compose up -d redisChoose either the local or hosted provider configuration below, configure the
GATEWAY_* client/server values as described under
Native HTTPS + SSE gateway API, then start
the worker and gateway.
After both are ready, run:
npm start -- doctordoctor exits with a non-zero status if the gateway is unavailable or the
CLI Bearer token is missing. Gateway health covers its BullMQ/Redis queue
connection. Provider connectivity is exercised by the worker when it processes
a turn, not by doctor.
LM Studio exposes the required OpenAI-compatible endpoints on port 1234 by
default. Start its local server, then configure .env:
OPENAI_API_KEY=
OPENAI_BASE_URL=http://127.0.0.1:1234
OPENAI_MODEL=
OPENAI_TRANSPORT=sseBehavior in local mode:
/v1is appended automatically whenOPENAI_BASE_URLcontains only an origin.- An API key is optional for
127.0.0.1,localhost, and::1. - With an empty
OPENAI_MODEL, the first identifier returned by/v1/modelsis selected. - Set
OPENAI_MODELexplicitly when the server exposes multiple generation models:
OPENAI_MODEL=google/gemma-4-12bThe local server must support typed response.created,
response.output_text.delta, and response.completed events and custom
function tools. With OPENAI_TRANSPORT=sse, it must accept stream: true
Responses requests; with websocket, it must accept response.create
messages. These event shapes follow the
OpenAI streaming Responses guide.
LM Studio documents its endpoints in the
OpenAI compatibility guide.
Provider settings are read from environment variables and .env.
local.json is not read by the application.
Leave OPENAI_BASE_URL empty and set the API key:
OPENAI_API_KEY=your-api-key
OPENAI_BASE_URL=
OPENAI_MODEL=
OPENAI_TRANSPORT=websocketWhen OPENAI_MODEL is empty in hosted mode, the harness uses
gpt-5.6-luna. An explicit model overrides this default:
OPENAI_MODEL=gpt-5.6-terraFor a non-loopback custom endpoint, an API key is still required.
Set OPENAI_TRANSPORT=websocket to use the
Responses API WebSocket mode.
The worker derives wss://api.openai.com/v1/responses for hosted OpenAI, or
converts an HTTP(S) OPENAI_BASE_URL to ws(s) and appends /responses.
Use OPENAI_WEBSOCKET_URL only when a compatible provider exposes a different
full WebSocket endpoint:
OPENAI_TRANSPORT=websocket
OPENAI_WEBSOCKET_URL=wss://provider.example/custom/responsesOne WebSocket is opened per queued agent turn and reused across its sequential
model/tool steps. Each step sends response.create; continuation sends only
the new tool outputs plus previous_response_id. stream and background
are omitted because streaming is implicit in WebSocket mode. The socket is
closed when the turn completes or fails.
This setting changes only the worker-to-provider transport. The CLI continues to use HTTPS plus SSE with the tinypaw gateway.
The worker, gateway, and CLI run as separate processes.
Terminal 1:
npm run workerTerminal 2:
npm run gatewayTerminal 3, one prompt:
npm start -- run "Explain the current project"Interactive chat:
npm start -- chatContinue using a known session:
npm start -- chat --session <session-id>
npm start -- run "Continue" --session <session-id>Reusing a session preserves its event stream and mutual-exclusion lease, but does not replay earlier messages into later model calls. It is not currently full conversational memory.
External clients can use the gateway as an OpenAI-compatible Responses API.
Set their base URL to https://<gateway-host>:<port>/v1 and use the gateway
API token as the OpenAI API key.
The compatibility surface exposes:
GET /v1/models
GET /v1/models/<model>
POST /v1/responses
GATEWAY_OPENAI_MODEL defines the virtual model ID exposed to clients and
defaults to tinypaw. It is an agent alias, separate from the provider model
selected by OPENAI_MODEL or Redis configuration.
The official OpenAI Node SDK can use the gateway directly:
import OpenAI from 'openai';
const client = new OpenAI({
apiKey: process.env.GATEWAY_API_TOKEN,
baseURL: 'https://localhost:8443/v1',
});
const stream = await client.responses.create({
model: 'tinypaw',
input: 'Explain the current project',
stream: true,
});
for await (const event of stream) {
if (event.type === 'response.output_text.delta') {
process.stdout.write(event.delta);
}
}When using the documented self-signed development certificate, configure the
client runtime to trust secrets/gateway-tls-cert.pem.
Supported request fields are model, input, instructions, and stream.
input may be a non-empty string or a non-empty Responses API input array.
Other fields return an OpenAI-shaped unsupported_parameter error instead of
being silently ignored.
With stream: false or no stream field, the request waits for the queued turn
and returns a completed Responses API JSON object. With stream: true, the
gateway returns text/event-stream and emits:
response.created
response.in_progress
response.output_item.added
response.content_part.added
response.output_text.delta
response.output_text.done
response.content_part.done
response.output_item.done
response.completed
Each compatibility request creates a new tinypaw session. Client-supplied
tools, previous_response_id, background mode, structured output controls,
and the other Responses API parameters are not currently supported. The
worker's built-in load_skill and load_config tool loop still runs
internally.
The native gateway API accepts turns over HTTPS and streams BullMQ lifecycle
events and the terminal result as
text/event-stream.
Submit a turn with the API token:
curl --fail \
--cacert secrets/gateway-tls-cert.pem \
-H "Authorization: Bearer <gateway-api-token>" \
-H "Content-Type: application/json" \
-d '{"input":"Explain the current project","sessionId":"api-demo"}' \
https://localhost:8443/v1/turnsThe 202 response contains jobId, turnId, sessionId, and a relative
eventsUrl. Connect to that URL with curl -N, fetch(), or browser
EventSource:
curl --fail --no-buffer \
--cacert secrets/gateway-tls-cert.pem \
"https://localhost:8443/v1/turns/<job-id>/events?token=<scoped-token>"The stream emits BullMQ state events and forwards normalized model events:
response.created
response.output_text.delta
response.completed
response.completed marks one model step, including steps that request a
function call. The separate completed or failed event terminates the whole
queued turn. The CLI prints each delta immediately and avoids printing the
same final result twice.
The job-scoped token in eventsUrl allows native EventSource to authenticate
without exposing the main API token. Treat the URL as sensitive and close the
client after the turn-level terminal event.
Bearer-authenticated endpoints also provide remote CLI data operations:
GET /v1/sessions
GET /v1/sessions/<session-id>
GET /v1/skills
GET /v1/skills/<skill-name>
PUT /v1/skills/<skill-name> {"content":"..."}
For host-side development, configure the GATEWAY_* values in .env and run
npm run gateway. The production Compose service supplies those values and
files through its container configuration.
The production stack is defined in compose.prod.yml and contains:
redis: private Redis 8 with password authentication, AOF persistence, and a named volume;worker: the continuously running Node.js BullMQ worker;gateway: the HTTPS ingress, native SSE job stream, and OpenAI-compatible Responses API;cli: an on-demand tools-profile service for CLI commands.
Create the production environment file and replace every placeholder:
cp .env.production.example .env.productionCreate the gateway secret files referenced by .env.production:
mkdir -p secrets
openssl rand -hex 32 > secrets/gateway-api-token.txtPlace a trusted certificate chain and its matching private key at:
secrets/gateway-tls-cert.pem
secrets/gateway-tls-key.pem
For loopback development only, a short-lived self-signed certificate can be created with:
openssl req -x509 -newkey rsa:2048 -sha256 -nodes \
-keyout secrets/gateway-tls-key.pem \
-out secrets/gateway-tls-cert.pem \
-days 1 -subj "/CN=localhost" \
-addext "subjectAltName=DNS:localhost,DNS:gateway,IP:127.0.0.1" \
-addext "extendedKeyUsage=serverAuth"For host-side development with that certificate, set:
GATEWAY_URL=https://localhost:8443
GATEWAY_API_TOKEN_FILE=./secrets/gateway-api-token.txt
GATEWAY_CA_CERT_FILE=./secrets/gateway-tls-cert.pem
GATEWAY_TLS_CERT_FILE=./secrets/gateway-tls-cert.pem
GATEWAY_TLS_KEY_FILE=./secrets/gateway-tls-key.pemThe entire secrets/ directory is gitignored. Ensure the files are readable
by Docker, then replace the remaining environment placeholders.
For hosted OpenAI, set OPENAI_API_KEY. For LM Studio running on the Docker
host, use:
OPENAI_API_KEY=
OPENAI_BASE_URL=http://host.docker.internal:1234
OPENAI_MODEL=google/gemma-4-12b
OPENAI_TRANSPORT=sseBuild and start the full production stack:
docker compose \
--env-file .env.production \
-f compose.prod.yml \
up -d --buildInspect status and logs:
docker compose --env-file .env.production -f compose.prod.yml ps
docker compose --env-file .env.production -f compose.prod.yml logs -f worker
docker compose --env-file .env.production -f compose.prod.yml logs -f gatewayRun CLI commands inside the production network:
docker compose --env-file .env.production -f compose.prod.yml run --rm cli doctor
docker compose --env-file .env.production -f compose.prod.yml run --rm cli run "Hello"
docker compose --env-file .env.production -f compose.prod.yml run --rm cli chat
docker compose --env-file .env.production -f compose.prod.yml run --rm cli session listStop containers while preserving Redis data:
docker compose --env-file .env.production -f compose.prod.yml downThe named Redis volume survives down and container replacement. The
following command also deletes persisted Redis data and must only be used when
that is intentional:
docker compose --env-file .env.production -f compose.prod.yml down --volumesProduction hardening currently included:
- Redis is not published to a host port.
- Redis starts with
appendonly yesandappendfsync everysec. - Worker and gateway wait for the Redis healthcheck; the tools-profile CLI waits for the gateway healthcheck.
- Node runs as the non-root
nodeuser in a read-only container filesystem. - Linux capabilities are dropped and
no-new-privilegesis enabled. - Worker receives a two-minute graceful shutdown window for in-flight jobs.
- Gateway terminates TLS 1.2 or newer and reads its API token, certificate, and private key through read-only Docker secrets.
- Only gateway publishes a host port. It uses separate
ingressand privatebackendnetworks; CLI joins onlyingress, while only worker joins the provideregressnetwork. - Container logs rotate at 10 MB with three retained files.
| Command | Description |
|---|---|
npm start -- run "<prompt>" |
Queue one turn and print the result. |
npm start -- chat |
Start an interactive session. |
npm start -- session list |
List stored sessions. |
npm start -- session show <id> |
Print session metadata and recent events. |
npm start -- skill list |
List stored skills. |
npm start -- skill get <name> |
Print a skill. |
npm start -- skill put <name> "<content>" |
Create or replace a skill. |
npm start -- doctor |
Check gateway TLS/readiness and CLI token configuration. |
If skill content is omitted, skill put prompts for it interactively:
npm start -- skill put reviewer| Variable | Default | Purpose |
|---|---|---|
OPENAI_API_KEY |
none | Hosted/custom API credential; optional for loopback endpoints. |
OPENAI_BASE_URL |
OpenAI default | Custom API origin or base path. /v1 is appended only to an origin-only URL. |
OPENAI_MODEL |
provider-dependent | Hosted default is gpt-5.6-luna; local default is the first discovered model. |
OPENAI_TRANSPORT |
sse |
Worker provider transport: sse or websocket. |
OPENAI_WEBSOCKET_URL |
derived | Optional full ws:// or wss:// Responses endpoint override. |
OPENAI_TIMEOUT_MS |
120000 hosted, 30000 local |
OpenAI-compatible request timeout. |
REDIS_HOST |
localhost |
Redis hostname. |
REDIS_PORT |
6379 |
Redis port. |
REDIS_PASSWORD |
none | Optional Redis password. |
REDIS_CONNECT_TIMEOUT_MS |
3000 |
Redis connection timeout. |
QUEUE_NAME |
agent |
BullMQ queue name; it must match between gateway and worker. |
MAX_RETRIES |
1 |
Maximum BullMQ job attempts. |
WORKER_CONCURRENCY |
1 |
Jobs processed concurrently by one worker. |
JOB_TIMEOUT_MS |
120000 |
Maximum time a CLI waits for the terminal SSE result. |
MAX_AGENT_STEPS |
6 |
Responses API iterations allowed per turn. |
MAX_TOOL_CALLS |
8 |
Function calls allowed per turn. |
MAX_AGENT_BYTES |
32768 |
Input and tool-result byte budget per turn. |
SESSION_TTL_SECONDS |
604800 |
Redis lifetime for session and sandbox data. |
GATEWAY_URL |
https://localhost:8443 |
Gateway base URL used by the CLI; HTTPS is required. |
GATEWAY_API_TOKEN |
none | Bearer token used by direct gateway/CLI processes; the production containers use GATEWAY_API_TOKEN_FILE. |
GATEWAY_API_TOKEN_FILE |
none | File containing the Bearer token; read by the gateway and CLI. |
GATEWAY_CA_CERT_FILE |
none | Additional PEM CA certificate used by the CLI. |
GATEWAY_CONNECT_TIMEOUT_MS |
3000 |
CLI gateway connection timeout. |
GATEWAY_HOST |
0.0.0.0 |
Gateway listen address inside its process/container. |
GATEWAY_PORT |
8443 |
Gateway HTTPS listen port. |
GATEWAY_TLS_CERT_FILE |
none | PEM certificate chain path; required by gateway. |
GATEWAY_TLS_KEY_FILE |
none | PEM private-key path; required by gateway. |
GATEWAY_MAX_BODY_BYTES |
65536 |
Maximum JSON request body size. |
GATEWAY_POLL_INTERVAL_MS |
250 |
BullMQ state polling interval per SSE stream. |
GATEWAY_HEARTBEAT_MS |
15000 |
SSE comment heartbeat interval. |
GATEWAY_OPENAI_MODEL |
tinypaw |
Virtual model ID exposed by the gateway's OpenAI-compatible API. |
Production Compose additionally uses GATEWAY_BIND_ADDRESS (default
0.0.0.0), GATEWAY_HTTPS_PORT (default 8443) for host-side publishing,
and GATEWAY_INTERNAL_URL (default https://gateway:8443) for its on-demand
CLI container. The internal hostname must be present in the TLS certificate.
It also accepts APP_IMAGE (default tinypaw:latest) and
REDIS_VOLUME_NAME (default tinypaw-redis-data).
The Redis configuration stored under harness:config:default, when present,
can override the agent's model, instructions, and limit values.
The harness uses these key families:
harness:session:<session-id>
harness:session:<session-id>:events
harness:session:<session-id>:lease
harness:sandbox:<turn-id>
harness:skill:<name>
harness:config:<namespace>
BullMQ creates its own keys using the configured queue name.
Atomic session, lease, and sandbox transitions use the tinypaw Redis
Function library. The application installs or replaces the library on its
first atomic operation and invokes its functions with FCALL.
Run static syntax checks:
npm run checkRun the complete test suite with Redis available:
npm testThe Redis integration tests use unique key names and remove their data after completion.
Set OPENAI_API_KEY for the hosted API, or set OPENAI_BASE_URL to a local
loopback endpoint.
Make sure the local server is running and that this request succeeds:
curl http://127.0.0.1:1234/v1/modelsSet OPENAI_TRANSPORT to exactly sse or websocket.
When overriding the derived endpoint, provide a full ws:// or wss:// URL
in OPENAI_WEBSOCKET_URL.
Load or download a generation model in the local server. Alternatively, set
OPENAI_MODEL to a valid model identifier returned by /v1/models.
Make sure the gateway and worker are running and that both use the same Redis
instance and QUEUE_NAME. A client timeout or SSE disconnect does not cancel
the queued job. Increase JOB_TIMEOUT_MS for slow local models.
Check that GATEWAY_URL uses https://, the URL hostname is present in the
certificate SANs, GATEWAY_CA_CERT_FILE trusts the issuing CA/certificate,
and the CLI token matches the gateway token. There is no insecure TLS bypass.
The turn exceeded its step, tool-call, or byte budget. Review
MAX_AGENT_STEPS, MAX_TOOL_CALLS, and MAX_AGENT_BYTES.