About psh.now
psh.now is an approval service for AI agents that pauses an agent's request until a person approves, denies, or replies. It supports agents that can use shell commands or HTTP and can be self-hosted.
Key features
- 01Human approval for agent requests
- 02Approve or deny from a phone
- 03Free-form replies to requests
- 04CLI and HTTP API
- 05Claude Code pre-tool hook
- 06Self-hosted deployment
Use cases
- 01Require approval before a destructive command
- 02Review a production action from a phone
- 03Answer an agent's question while it is running
- 04Gate Claude Code Bash commands
Configuration
Remote servers need no local install. Pick your client, then copy the config or install in one click.
https://psh.now/mcpclaude mcp add --transport http passy-ai-psh-now https://psh.now/mcp{"mcpServers":{"passy-ai-psh-now":{"type":"http","url":"https://psh.now/mcp"}}}Open in Cursor{"servers":{"passy-ai-psh-now":{"type":"http","url":"https://psh.now/mcp"}}}Open in VS Code{"mcpServers":{"passy-ai-psh-now":{"type":"http","url":"https://psh.now/mcp"}}}{"mcpServers":{"passy-ai-psh-now":{"serverUrl":"https://psh.now/mcp"}}}Run commands in a terminal; paste JSON into the client’s MCP config and restart. Servers that need sign-in will prompt on first connect.
https://psh.now/mcpAuth requiredTools
Details
From the project READMEpsh.now
Human approval for AI agents: one host, one key, one boolean.

An agent that needs a decision sends a short title and message to psh.now and blocks. You tap Approve or Deny from one page, or answer in your own words, and the agent continues with the result. It exists because agents stall in terminals nobody is watching.
- Works with any agent that can run a shell command or make an HTTP request. No SDK, no MCP.
- One page for every agent and every machine, including sandboxes you cannot attach to.
- Self hostable: MIT licensed, two containers, three environment variables.
┌────────────┐ blocking POST / ┌────────────────┐ WebSocket ┌─────────┐
│ AI agent │ ───────────────────────► │ psh.now gateway │ ─────────────► │ phone │
│ (terminal) │ │ (Elysia + │ │ (PWA) │
│ │ ◄─────────────────────── │ Postgres) │ ◄───────────── │ │
└────────────┘ { approved: true } └────────────────┘ decision └─────────┘
The request is a title and a message, not only a shell command: a command to run, an action to take, or a question to answer.
Where to go next
| I want to | Read |
|---|---|
| install it and answer my first request | Quick start |
| run it on my own server | docs/self-hosting.md or /self-hosting on any instance |
| understand the flow end to end | docs/how-it-works.md |
| call the API directly | HTTP API |
| paste a prompt into an agent | docs/prompt-templates.md |
| report a vulnerability | SECURITY.md |
| contribute | CONTRIBUTING.md |
Stack
| Layer | Choice |
|---|---|
| Runtime | Bun |
| HTTP / WS | Elysia |
| Database | PostgreSQL + Drizzle ORM |
| Realtime | WebSockets + Postgres LISTEN/NOTIFY (multi-node fan-out) |
| Frontend | Zero-build PWA (vanilla ES modules + service worker) |
Quick start
# 1. install
bun install
# 2. configure
cp .env.example .env # then set DATABASE_URL / PUBLIC_BASE_URL
createdb psh.now # if it doesn't exist
# 3. migrate
bun run migrate
# 4. run the gateway
bun run dev # http://localhost:3000
Then, in the project you want to protect:
bun /path/to/psh/cli/psh.ts init
This creates a workspace, prints your API key, renders a pairing QR code, and
writes a managed rule block into ./agents.md. Scan the QR with your phone to
open the PWA, tap Pair this device, and you're live.
Or create a channel in the browser
Open /setup (e.g. http://localhost:3000/setup). It creates a workspace,
shows the API key once, and renders the pairing QR. Pairing a device lands on
the dashboard, which immediately sends a test approval so you can confirm
alerts and the full approve/deny loop without a terminal. The dashboard's
Send a test approval button repeats it anytime, and Invite mints fresh
pairing codes from any already-paired device.
Manual check
# commands, actions, and questions all use the same call
psh ask "Drop the users table and reseed?" --title "Database reset"
Claude Code hook
Register the hook once in .claude/settings.json:
{
"hooks": {
"PreToolUse": [
{
"matcher": "Bash",
"hooks": [{ "type": "command", "command": "psh hook" }]
}
]
}
}
psh hook reads Claude's PreToolUse JSON on stdin and writes the
decision contract on stdout (permissionDecision: "allow" | "deny"). Every
Bash command waits for your phone. Non-Bash tools pass straight through. Set
PSH_SKIP_PATTERN to a regex to auto-allow read-only commands:
PSH_SKIP_PATTERN='^(ls|cat|git status|rg)\b' psh hook
Using a hosted instance with your own key
Signing up gives you a workspace and one key. That key is the agent's credential: it is what makes an ask appear in your account rather than someone else's.
- Get the key. Open the hosted dashboard, create a workspace, and copy the key. It is shown once. The same page renders a pairing QR for a phone.
- Put it in the project, not a shell profile. Install the skill inside the
project you want the agent to work in; it writes
<project>/.psh/keys.json(and a machine record at~/.psh/keys.json). Keys are per project on purpose, so two projects can belong to two different workspaces, and the file never belongs in version control. - Ask.
<project>/.psh/keys.jsonis what the CLI and the skill'spshshim read, searching upward from the working directory. Nothing global has to be set. - Approve from the notch or the phone. The notch does not use the agent key at
all: it pairs a device (dashboard: Devices, then Connect a device) and stores a
per-device token at
~/.config/psh-notch/device.json, mode 0600. That token can approve and read, cannot rotate keys, and can be revoked per device.
So the two credentials are separate on purpose: the key lets an agent ask, the device token lets a human answer. Copying a key between machines moves the agent; copying the device token moves the approval screen.
Hosting & self-hosting
A hosted psh.now and a self-hosted one are the same single process. The only
thing that makes a deployment "public" is PUBLIC_BASE_URL:
PUBLIC_BASE_URL=https://psh.now
With that set, every generated pairing link becomes
https://psh.now/pair/<code>, openable from any phone, anywhere. The
agent endpoint is simply the same origin:
curl -s -X POST https://psh.now \
-H "x-psh-key: psh_live_…" \
-H "Content-Type: application/json" \
-d '{"title":"Reinstall deps","message":"rm -rf node_modules && npm install --force"}'
The PWA derives its WebSocket URL from location.host, so serving over HTTPS
automatically yields wss://… with no rebuild. One instance serves many
isolated workspaces/tenants.
The simplest deployment (no Docker, no reverse proxy)
Bun is the whole runtime. Point it at Postgres and run it:
cp .env.example .env # set DATABASE_URL + PUBLIC_BASE_URL
bun src/db/migrate.ts
bun src/index.ts
That's it. Put it under systemd (or tmux/pm2), and you have a host node.
HTTPS / WSS without Caddy
Bun terminates TLS itself, no Caddy, no nginx. Get a cert (e.g. from Let's Encrypt) and point psh.now at it:
export TLS_CERT_FILE=/etc/letsencrypt/live/psh.now/fullchain.pem
export TLS_KEY_FILE=/etc/letsencrypt/live/psh.now/privkey.pem
export PUBLIC_BASE_URL=https://psh.now
bun src/index.ts
The server logs https://…, and the PWA automatically uses wss://…. If
TLS_CERT_FILE/TLS_KEY_FILE are unset, it serves plain HTTP (and says so on
boot). Use certbot certonly with a deploy hook to
renew, or any certificate you already manage.
A reverse proxy is only useful if you already run one, want to host many services on one IP, or need automatic cert renewal without touching psh.
deploy/Caddyfileis provided for that case, it is optional.
Blocking vs proxy timeouts.
POST /holds the connection open until a human answers. Cloudflare and many proxies cut an origin response off at ~100s (Cloudflare returns 524). KeepDEFAULT_TIMEOUT_MSbelow your proxy's limit (the default is 90s) or run psh.now directly / self-hosted without a CDN. The agent must treat a 524/timeout as not approved (the skill says so).
Docker (optional)
Dockerfile + docker-compose.yml bundle Postgres and the gateway if you
prefer containers. They are not required and are not the default path:
cp .env.example .env
# edit PUBLIC_BASE_URL (or keep http://localhost:3000 for local)
docker compose up -d --build
Migrations are idempotent, so they are safe to run on every boot.
Integration: a key in agents.md (or a skill)
The whole wire-up is a host, a key, and one instruction. psh init writes the
block below into ./agents.md with your real host and key
(see templates/agents-snippet.md):
## Ask before you act
Before any destructive, irreversible, or production action, ask first:
curl -s -X POST https://psh.now \
-H "x-psh-key: psh_live_…" \
-H "Content-Type: application/json" \
-d '{"title":"<short label>","message":"<what you are about to do>"}'
Proceed only if the response is {"approved":true}. Otherwise do not act.
Harnesses that support skills can load
skills/psh-now/SKILL.md instead (or as well). It teaches
any LLM the same contract in the Anthropic Agent Skills / Hermes format, with a
metadata.hermes block, and adds guidance on when to ask and when not to.
Install the skill (same shape as here.now's):
# if you have npm/npx: one command, so the harness asks for approval once.
# It installs machine-wide and mints in the same run.
npx skills add passy-ai/psh-now-skill -g -y
# no npm? the hosted installer (works in a sandbox too, no credentials needed)
curl -fsSL https://psh.now/install.sh | sh
Then give the agent a host and key (environment or agents.md):
export PSH_BASE_URL="https://psh.now"
export PSH_API_KEY="psh_live_..."
The server hosts the skill and installer itself: /skill.md, /install.sh,
/llms.txt, /llms-full.txt, and the /.well-known/* manifests.
That is the entire integration surface for an arbitrary agent:
- ask: the model follows
agents.md/ the skill and calls the host. Any LLM with shell or HTTP tool access can do this. - gate:
psh hookwired into a harness's pre-tool hook, which cannot be skipped. Optional, and only where the harness supports it.
The API key in
agents.mdis a secret. If that file is committed, gitignore it or keep the key inPSH_API_KEYand reference the env var instead.
How the block works
- Agent → gateway. The agent
POSTs{ title, message, kind?, cwd?, agent? }toPOST /(or/api/v1/hook) with headerx-psh-key. (commandis still accepted as a legacy alias formessage.) - Risk scan. The title + message are scanned against a rule set
(
rm -rf,--force,sudo,curl | sh,DROP TABLE, force-push, …) and bucketedlow → criticalwith human-readable reasons. - Hold. The request's promise is parked in an in-process waiter registry
with a timeout. The HTTP connection stays open, the terminal freezes.
Async option: send
wait:falseand the call returns at once with apollUrl, so nothing blocks and no proxy/CDN timeout can cut it off. Poll withGET /api/v1/prompts/:id?wait=30. - Alert. The prompt is broadcast to every paired device for that tenant.
- Decision. The phone sends
{ type: "decision", promptId, approved }over its WebSocket (orPOST /api/v1/prompts/:id/decisionas a fallback). The decision is persisted atomically (pending → approved/denied), fanned out overNOTIFY psh_decisions, and the waiter resolves. - Unblock. The agent receives
{ approved: true|false }and acts.
Timeouts and dropped agent connections auto-deny (status: "expired"), so a
lost phone can never wedge a build forever. Pending prompts whose answer window
has passed are swept to expired (async requests have no waiter to time out).
HTTP API
| Method | Path | Auth | Purpose |
|---|---|---|---|
POST | / · /api/v1/hook | x-psh-key | Approval request (blocking, or wait:false) |
POST | /mcp | x-psh-key | MCP: one tool, psh_ask (streamable HTTP, JSON) |
GET | /.well-known/mcp.json | - | MCP descriptor for clients and scanners |
POST | /api/v1/channels | - | Provision workspace (+ pair:true for a pairing link) |
POST | /api/v1/claims | key / device | Request a claim code for an anonymous workspace |
POST | /api/v1/claims/:id/verify | - | Verify the code; makes the workspace permanent |
POST | /api/v1/pairing | key / device | Mint a pairing link/QR |
GET | /api/v1/qr | - | Render any string as an SVG QR code |
GET | /api/v1/pairing/:token | - | Validate a pairing token |
POST | /api/v1/pairing/:token/claim | - | Exchange token for a device token |
GET | /api/v1/me | x-psh-key | Workspace overview |
GET | /api/v1/prompts | key / device | List prompts (?status=, ?limit=) |
GET | /api/v1/prompts/:id | key / device | Fetch a prompt; ?wait=<s> long-polls |
POST | /api/v1/prompts/:id/cancel | key / device | Cancel a pending request |
POST | /api/v1/connections | key / device | Register a connection (agent/project) |
GET | /api/v1/connections | key / device | List connections (one account → many) |
GET | /api/v1/connections/:id | key / device | A connection and its requests |
DELETE | /api/v1/connections/:id | key / device | Delete a connection |
POST | /api/v1/test | key / device | Send a non-blocking test approval |
POST | /api/v1/prompts/:id/decision | key / device | Decide over HTTP (WS fallback) |
GET | /api/v1/devices | key / device | List paired devices |
DELETE | /api/v1/devices/:id | key / device | Revoke a device |
GET | /api/v1/keys | key / device | List agent keys |
POST | /api/v1/keys | key / device | Mint a recoverable agent key |
GET | /api/v1/keys/:id/reveal | key / device | Reveal a key's raw value to the operator |
DELETE | /api/v1/keys/:id | key / device | Revoke an agent key |
GET | /api/v1/health | - | Health probe |
WS | /ws?token=<deviceToken> | device token | Live prompt stream + decisions |
Interactive docs at /docs (Swagger).
MCP
The HTTP call is the contract; MCP is a second front door for clients that cannot install a skill (hosted builders, connector UIs, apps that only speak the protocol). One endpoint, one tool, no OAuth, no server-initiated stream -- a thin JSON-RPC wrapper around the same handler, so metering, expiry and the decision path cannot drift between the two surfaces.
claude mcp add --transport http psh https://psh.now/mcp --header "x-psh-key: psh_agent_..."
{ "mcpServers": { "psh": { "url": "https://psh.now/mcp",
"headers": { "x-psh-key": "psh_agent_..." } } } }
psh_ask takes {title?, message, timeoutSec?}, blocks, and returns
{"approved":bool,"status":"approved|denied|answered|expired","answer":string|null,"promptId":"..."}.
Only approved: true means yes; denied, expired and errors all mean do not act. MCP is
cooperative like the skill: it cannot stop an agent that never calls it. The gate is the hook.
Protocol details, error codes and the reasoning behind the smaller surface: docs/mcp.md.
WebSocket frames
Server → phone:
{ "type": "hello", "workspace": {…}, "device": {…}, "pending": [...] }
{ "type": "prompt.created", "prompt": { "id": "pr8x1k…", "title": "Reinstall deps", "message": "rm -rf node_modules", "kind": "command", "risk": "critical", "riskReasons": [...] } }
{ "type": "prompt.decided", "promptId": "…", "approved": true }
Phone → server:
{ "type": "decision", "promptId": "pr8x1k…", "approved": true, "reason": "ok" }
{ "type": "ping" }
Security model
- API keys are shown exactly once and stored only as SHA-256 hashes.
- Device tokens are random, stored hashed, and revocable per device.
- Pairing tokens are single-use and expire (default 10 min).
- Short IDs. All public identifiers are base62, ≤ 32 chars, so every URL
stays YouTube-short (
/pair/UpDbWPKjQjoAqVtLdehnwQ). - Short-lived credentials only cross the wire; no secrets are ever sent to the agent or embedded in URLs after pairing.
- Every decision is audited in the append-only
audit_log.
Multi-node / scaling
The blocked promise lives on the node that accepted the agent request; the
phone may be connected to a different node. NOTIFY psh_decisions broadcasts
every decision to all nodes, and each node resolves any matching local waiter.
No sticky sessions, no Redis required. Postgres is the only coordination
primitive.
Project layout
src/
index.ts Elysia app + graceful shutdown
env.ts Validated configuration
db/
schema.ts Drizzle tables (tenants, devices, prompts, …)
index.ts postgres.js pool + Drizzle client
migrate.ts Migration runner
lib/
ids.ts Short base62 ID generator
crypto.ts API key generation, SHA-256, header parsing
risk.ts Command risk scanner
waiters.ts In-process blocked-request registry
realtime.ts WebSocket per-tenant hub
pubsub.ts Postgres LISTEN/NOTIFY fan-out
tenant-auth.ts Request authentication
http.ts Typed HTTP errors
services/ Business logic (tenants, devices, prompts, decisions)
routes/ auth · hook · prompts · ws
public/ PWA (index/pair HTML, app.js, sw.js, manifest)
cli/psh.ts init · pair · ask · hook · status · doctor
tests/e2e.ts Full approval-loop integration test
drizzle/ Generated SQL migrations
Testing
bun tests/e2e.ts # boots the gateway, pairs a fake phone, exercises approve/deny/timeout
bunx tsc --noEmit # type check
CLI
psh init Create a workspace + pairing QR
psh pair Mint a new pairing link
psh ask "cmd" Block until a human approves/denies [--timeout <seconds>]
psh hook Agent hook (reads JSON on stdin)
psh status Workspace + pending requests
psh doctor Connectivity + credential check
Licence
MIT, Copyright (c) 2026 Absolum. See LICENSE. The hosted instance at psh.now is a service, not part of this licence.
Comments
New to MCP?
From what the protocol is to installing your first server, these answers cover most first questions.
An open protocol that defines how AI applications connect to external tools and data in a uniform way. Clients talk to the model; MCP servers provide tools, resources and prompt templates. They exchange standard messages, so one server can be reused by many clients.