Work

Hooks

Send session, command, file, tunnel, watch, Health and AI events from Gatesys SSH to your own signed webhook, a local command or a JSONL file, masked.

Pro

Hooks are part of Gatesys Pro — free for 3 months, then $20 a year. See plans

Hooks send what happens in Gatesys SSH to destinations you add: a signed webhook, a local command, or a local JSONL file. Nothing is sent until you add a hook, and then only to it. There is no Gatesys destination, default or fallback.

Add a hook

  1. Open Settings › Automation › Hooks and click Add hook. The first-run setup’s Hooks step shows the same card.
  2. Give it a Name and choose what it Sends to: Webhook, Command or File.
  3. Fill in the destination:
    • Webhook: the URL.
    • Command: the Program, as an absolute path, and its Arguments, one per line.
    • File: the File, as an absolute path, and the size to Rotate at, in MB.
  4. Under Events, pick the groups this hook receives: Sessions, Commands, AI, Files & changes, Tunnels and Watches.
  5. Set Include conversation text and Mask hosts, addresses, paths and logins. See What is sent.
  6. Check the Preview. It is one event exactly as this hook would send it, built by the delivery code from a sample around one of your saved hosts.
  7. Click Add hook.

Hooks are saved in settings.json under "hooks", so you can also add them there.

On the card

  • The status line says when the hook last delivered, the last error and when it retries, how many events are queued, and how many were dropped when the queue was full.
  • Send test event sends one hook.test event at once and says whether it arrived.
  • Edit opens the hook again, with Remove at its foot.
  • The switch turns a hook off without losing its settings.

Adding, removing or re-pointing a hook is written to the audit log. A delivery that fails shows on the card and nowhere else: it never becomes an audit entry or an event, so a destination that is down cannot generate traffic for itself or for another hook.

Without Pro

Hooks you added stay listed, paused, and send again when Pro is back.

Events

Every event is one JSON object, the envelope:

{
  "id": "5b0e6c1a-…",
  "ts": "2026-09-27T09:14:03.120Z",
  "type": "ai.ask",
  "schema": 1,
  "app": { "name": "gatesys-ssh", "version": "1.0.0" },
  "actor": "alice@laptop",
  "host": { "id": "…", "name": "‹H-5c1e09d2›" },
  "sessionId": "…",
  "data": { "…": "metadata, always sent" },
  "content": { "…": "conversation text, only with Include conversation text on" }
}

id is one id per happening, the same at every hook, so a receiver can drop duplicates. actor is your local account. host and sessionId appear when the event is about a host or happened in a session.

GroupEvent types
Sessionssession.connected, session.failed, session.closed, doctor.report (Hop Doctor’s diagnosis), dossier.probe (a host-facts read, as counts, never the facts), and the audit entries for sessions, refused connections, keys, the vault and hosts
CommandsThe audit entries for snippet runs and for Farabi commands inserted or run
AIai.ask (a question to Farabi), ai.explain (either Explain), ai.compile (a sentence a model read into a watch, a setup draft or a config change)
Files & changeschange.state and change.step for Safe Change, and the audit entries for SFTP and config changes
TunnelsThe audit entries for tunnels
Watcheswatch.fired, and health.signal when a Health signal is raised, gets worse or clears

A health.signal event’s data carries the signalId, its kind (such as disk-fill or cpu-hog), severity, change (raised, escalated or cleared), class (issue or risk) and, for a forecast, etaMinutes. Its content, sent only with Include conversation text on, holds the signal’s title and risk, since they name processes, units and mounts.

Every audit entry is also an event, typed audit.<kind>, such as audit.tunnel. ai.* events are sent only when a model was actually asked: a sentence the rules read alone is none. ai.ask carries the model and effort that answered, and the ids of the skills in its prompt, never their text. Every hook receives hook.test, whatever its groups.

What is sent

  • Metadata, always: what happened, where and when, such as a session’s hop names, a command’s risk rating or the model that answered.
  • Conversation text, only with Include conversation text on. It is off by default. It adds the question you typed, Farabi’s answer and suggested commands, an Explain’s reading, the sentence a model read into a draft and what it became, and a watch’s matched line.

Two safeguards apply to every string, in data, content and the host name:

  1. Redaction, always. Keys, tokens, passwords and private keys become [redacted …], the same rule every prompt goes through, whatever the switches say.
  2. Masking, on by default. With Mask hosts, addresses, paths and logins on, host and domain names, IP and MAC addresses, file paths and user@host logins, and your saved hosts’ own names and addresses, become placeholders: ‹H-…› a host, ‹A-…› an address, ‹P-…› a path, ‹U-…› a login.

The placeholders come from a secret salt kept in the vault. The same value is the same placeholder in every event from this installation, so a receiver can follow one server through a day without learning which server it is. Loopback addresses, port numbers and quoted text stay as written, and ids, kinds, states, risks and model names are never masked. A string too large or too crowded to mask is sent as [not sent: could not be masked], never bare.

Webhook

  • The URL must be https://, or plain http:// to this machine only, with no login in it. Redirects are never followed.
  • Batches. Events go in one POST of up to 50 events, or whatever gathered in 5 seconds. The body is { "schema": 1, "delivery": "<uuid>", "sentAt": "…", "events": [ … ] }.
  • Headers. X-Gatesys-Event is the event type, or batch when a request holds several. X-Gatesys-Delivery is the body’s delivery, new on every attempt; drop duplicates on each event’s id. X-Gatesys-Signature is sha256= followed by the HMAC-SHA256 of the raw body under the hook’s signing secret.
  • Retries. Anything but a 2xx, or no answer within 10 seconds, keeps the batch and retries after 2 s, 4 s, 8 s and so on, up to 10 minutes apart.
  • The queue. Undelivered events wait in a queue of up to 1,000 per hook, sealed under the vault key, which survives a restart. Past that, the oldest go first and are counted as dropped.

The signing secret

The app makes a secret when you save the hook and keeps it in the vault. Use my own lets you paste one of at least 16 characters instead. Copy secret puts it on the clipboard for your receiver, and New replaces it: the old one stops verifying. The secret is in no body or header.

In settings.json, a webhook names where its secret is, never the secret itself: {"vault": "<id>"}, set by the app, or {"env": "GATESYS_HOOK_SECRET"} to read it from the environment the app started in.

Verify a request

Compute the HMAC over the raw body, before parsing it, and compare in constant time. In Node:

import { createHmac, timingSafeEqual } from 'node:crypto'

function verify(rawBody, header, secret) {
  const expected = Buffer.from(`sha256=${createHmac('sha256', secret).update(rawBody).digest('hex')}`)
  const given = Buffer.from(String(header ?? ''))
  return given.length === expected.length && timingSafeEqual(given, expected)
}
// verify(req.rawBody, req.headers['x-gatesys-signature'], process.env.GATESYS_HOOK_SECRET)

A complete receiver that checks each signature is in ~/.gatesys-ssh/hooks/verify-signature.mjs. Run it with the hook’s secret, then point a webhook at http://127.0.0.1:8787/gatesys:

GATESYS_HOOK_SECRET=<the hook's secret> node ~/.gatesys-ssh/hooks/verify-signature.mjs

Command

  • No shell. The program is an absolute path and its arguments are passed as they are, so nothing in an event can become a command line.
  • One run per event, with the envelope as a single line of JSON on standard input, and GATESYS_EVENT and GATESYS_EVENT_ID set.
  • A minimal environment: otherwise only PATH, HOME, USER, LANG and the temp folder.
  • One run at a time, stopped after 10 seconds. Its output is ignored; only its exit status counts. Up to 200 events wait in memory for their turn.

Keep your scripts in ~/.gatesys-ssh/hooks/. A command hook added by editing settings.json does not run until you approve it. See Command hooks from the file.

File

  • One envelope per line, appended to the file, which is created and kept readable only by you (mode 0600).
  • Rotation. Past the size you set, 10 MB unless you change it, the file becomes name.1, and older ones name.2 and name.3.
  • Symlinks are refused rather than followed.

Choosing File fills in ~/.gatesys-ssh/logs/events.jsonl. Change it to any absolute path you like.

Something unclear or wrong? Tell us.