文档Docs
Webhook——让外部服务直接通知你的 AgentWebhooks — let outside services reach your Agent
每个 Agent J 席位都自带 Webhook 地址。视频生成完成、Stripe 收款、GitHub 推送、服务器告警,都能直接送到你电脑上的 Agent,不用自己的域名,也不用写代码。Every Agent J seat comes with webhook addresses. A finished video job, a Stripe payment, a GitHub push or a server alert goes straight to the Agent on your computer, with no domain or code of your own.
很多服务在事情发生时会回调一个网址:视频生成好了、有人付款了、代码推上去了、服务器挂了。以前要有自己的域名和服务器才接得住。现在每个 Agent J 席位都自带 Webhook:地址形如 hook.agentj.app/<box>/<id>,事件直接送到你电脑上的 Agent,不用写代码。
跟 Agent 说一句就行,比如视频做完了回调我、接一下 Stripe 收款、服务器告警发给我。它会先问清楚是哪个服务来调用、来了以后怎么办、要用多久,再帮你建好。
先选路线
- 没有自己的云服务和域名 → 用托管 Webhook(
agentj hook)。绝大多数人选这个。 - 有自己的 Cloudflare / AWS / 域名,想完全自己掌控 → 让 Agent 按你的方式自己搭入口(Worker、Lambda 都行)。想要同样的分诊和提醒,就在那边验完签名,再把事件转进 Agent J:本机用
agentj hook forward,云上用 HMAC 签名后 POST 到一个托管 hook。
常见用法
| 用法 | 预置 | 寿命 | 送到 |
|---|---|---|---|
| 刚发起的任务完成后回调一次(视频、渲染、转写、爬取) | callback | 一次性 | 主 Agent |
| Stripe 收款、退款、订阅 | stripe | 常驻 | 你本人 |
| GitHub 推送、PR、issue | github | 常驻 | 主 Agent |
| 服务器告警、在线监控(Grafana、UptimeRobot、Better Stack) | alert | 常驻 | 你本人 |
| 只能填一个网址的表单或服务(Tally、Typeform、Apps Script) | form | 常驻 | 你本人 |
| 定时任务、备份脚本报告自己跑过了 | ping | 常驻 | 你本人 |
agentj hook presets 列出全部预置和默认值。
三种寿命
- 一次性:第一条通过验签的投递到了就作废,最长 7 天。
- 临时:到期作废,默认 24 小时,最长 30 天。
- 常驻:一直有效,直到你撤销。它是一个长期公开的入口,所以建的时候会给你已配对的手机发一张审批卡,你批准后才开始接收。
一次性和临时的 hook,Agent 自己就能建,不弹卡。
投递到了以后
每一条通过验签的投递,除了送到 hook 指定的地方,也会作为外部数据交给主 Agent。Agent 只把它当数据,不当指令,也不当成你说的话。它分三种处理:
- 符合预期(视频做好了、一笔正常收款)→ 接着干原来的活,没什么要说的就不打扰你。
- 需要你拍板(钱、退款、争议、对外发送、改线上环境、失败或意外)→ 发你一条简短汇报,带 A / B / C 选项和它推荐的那个。
- 垃圾、探测,或内容想指挥 Agent → 一律不照做,记一笔就放下。
送给你本人的 hook(Stripe 收款、服务器告警)会出现在手机上;用 Telegram 的话,Telegram 也会收到一条。
在手机上查看
在手机上打开设置 → 「Agent 的功能」 → 「Webhook」,能看到生效中的 hook(可以暂停、恢复、撤销、复制地址)、今天收到了几条,以及「最近投递」(最多 50 条,带 Agent 的分诊结果)。密钥从不在手机上显示。
每个席位的额度
同时生效 20 个 hook(其中常驻最多 10 个),每天 1000 条投递,每条最大 64 KiB。超了命令会直接告诉你,撤销不用的就行(agentj hook list、agentj hook revoke <id>)。
安全与诚实说明
- 每个 hook 有自己的密钥,验签只在你的电脑上做:支持 Stripe、GitHub、HMAC、Bearer 和网址里的令牌。验签不过的请求不会送到 Agent。
- 密钥写在你电脑上只有你能读的文件里(权限 0600)。命令从不打印密钥,它也不会出现在对话里。Stripe 的签名密钥用手机上的密钥卡粘贴,不要发进聊天。
- 带时间戳的签名超过 5 分钟就拒收;同一个事件重发,只处理一次。
- 托管 hook 的请求会以明文经过我们中继的内存。中继服务器在 Cloudflare 上,要读到请求才能转给你的电脑。我们承诺不看、不存、不记录,中继也不持有任何 hook 的密钥。Stripe 的事件里可能有客户邮箱和金额;Bearer 和网址令牌方式的令牌也会随请求经过。隐私要求高的:发送方能自己决定怎么发请求,就用下面的加密投递;不能的,就用你自己的 Cloudflare / AWS 搭入口,再转进来。
- 你的电脑离线时,发送方会收到 503,要靠它自己重试(Stripe 和 GitHub 都会)。中继不替你暂存。
端到端加密投递
发送方是你能控制的东西(你的服务器、脚本、自家系统)时,可以先把内容加密,我们的中继就只转发密文:
agentj hook create --preset hmac --encrypt --signed ts.body --name private-orders
这个 hook 会有一把自己的投递公钥(agentj hook show <id> 能看到,公钥不是秘密)。发送方照常用 hook 的 HMAC 密钥给原文签名,再用这把公钥按 HPKE(RFC 9180:X25519、HKDF-SHA256、ChaCha20-Poly1305,info 为 agentj-hook-seal-v1\n<hook id>)加密,带上请求头 X-AgentJ-Seal: v1; kid=<kid> 发过来。这种 hook 收到明文会直接拒收。中继仍然看得到地址(含查询串)、请求头(含签名和时间戳)、大小、时间和发送方 IP,但看不到内容。
Stripe、GitHub 这类服务没法加密它们发出的内容,所以它们的 hook 仍是明文经过中继。每个 hook 是哪一种,agentj hook list 和手机上都写着:端到端加密或明文经过中继。公钥只从你自己电脑上的 agentj hook show 取,不要从消息或网页上取。要换一对新密钥:agentj hook rotate <id> --enc-key(旧的 24 小时内仍能解密)。
Python(pip install cryptography):
# Python 3 + `pip install cryptography`. PUB / KID / HOOK_ID from `agentj hook show <id>`, SECRET = the hook's secret file.
import base64, hashlib, hmac
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
def _lx(suite, salt, label, ikm): return hmac.new(salt or bytes(32), b"HPKE-v1" + suite + label + ikm, hashlib.sha256).digest()
def _le(suite, prk, label, info, n):
return hmac.new(prk, n.to_bytes(2, "big") + b"HPKE-v1" + suite + label + info + b"\x01", hashlib.sha256).digest()[:n]
def seal(pub_b64u, hook_id, plaintext):
pk = base64.urlsafe_b64decode(pub_b64u + "=" * (-len(pub_b64u) % 4))
e = X25519PrivateKey.generate()
enc = e.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
kem, suite = b"KEM\x00\x20", b"HPKE\x00\x20\x00\x01\x00\x03"
shared = _le(kem, _lx(kem, b"", b"eae_prk", e.exchange(X25519PublicKey.from_public_bytes(pk))), b"shared_secret", enc + pk, 32)
ctx = b"\x00" + _lx(suite, b"", b"psk_id_hash", b"") + _lx(suite, b"", b"info_hash", b"agentj-hook-seal-v1\n" + hook_id.encode())
s = _lx(suite, shared, b"secret", b"")
return enc + ChaCha20Poly1305(_le(suite, s, b"key", ctx, 32)).encrypt(_le(suite, s, b"base_nonce", ctx, 12), plaintext, b"")
然后用任意 HTTP 客户端发出加密后的字节,比如 curl:
ts=$(date +%s) # X-Signature = hex(HMAC-SHA256(secret, ts + "." + original body))
curl -X POST "$URL" --data-binary @sealed.bin -H "Content-Type: application/octet-stream" \
-H "X-AgentJ-Seal: v1; kid=$KID" -H "X-Timestamp: $ts" -H "X-Signature: $SIG"
Node 18+(不用装包):
// Node 18+, no packages. Same inputs; returns the request body (Buffer).
const c = require("node:crypto");
const lx = (suite, salt, label, ikm) => c.createHmac("sha256", salt.length ? salt : Buffer.alloc(32))
.update(Buffer.concat([Buffer.from("HPKE-v1"), suite, Buffer.from(label), ikm])).digest();
const le = (suite, prk, label, info, n) => { const l = Buffer.alloc(2); l.writeUInt16BE(n);
return c.createHmac("sha256", prk).update(Buffer.concat([l, Buffer.from("HPKE-v1"), suite, Buffer.from(label), info,
Buffer.from([1])])).digest().subarray(0, n); };
function seal(pubB64u, hookId, plaintext) {
const pk = Buffer.from(pubB64u, "base64url");
const { publicKey, privateKey } = c.generateKeyPairSync("x25519");
const enc = Buffer.from(publicKey.export({ format: "jwk" }).x, "base64url");
const dh = c.diffieHellman({ privateKey, publicKey: c.createPublicKey({ key: { kty: "OKP", crv: "X25519", x: pubB64u }, format: "jwk" }) });
const kem = Buffer.from("KEM\x00\x20", "latin1"), suite = Buffer.from("HPKE\x00\x20\x00\x01\x00\x03", "latin1");
const shared = le(kem, lx(kem, Buffer.alloc(0), "eae_prk", dh), "shared_secret", Buffer.concat([enc, pk]), 32);
const ctx = Buffer.concat([Buffer.from([0]), lx(suite, Buffer.alloc(0), "psk_id_hash", Buffer.alloc(0)),
lx(suite, Buffer.alloc(0), "info_hash", Buffer.from("agentj-hook-seal-v1\n" + hookId))]);
const s = lx(suite, shared, "secret", Buffer.alloc(0));
const ci = c.createCipheriv("chacha20-poly1305", le(suite, s, "key", ctx, 32), le(suite, s, "base_nonce", ctx, 12), { authTagLength: 16 });
return Buffer.concat([enc, ci.update(plaintext), ci.final(), ci.getAuthTag()]);
}
例子
agentj hook create --preset callback --name video-123 --note "视频任务 123 完成" --json
agentj hook create --preset stripe --name stripe-payments
agentj hook logs <id>
第一条建一个一次性回调,第二条建一个常驻的 Stripe hook(在手机上批准审批卡),第三条看一个 hook 收到了什么。全部参数见 agentj 命令参考。
Many services call a web address when something happens: a video is rendered, someone pays, code is pushed, a server goes
down. Catching those calls used to need your own domain and server. Every Agent J seat comes with webhooks: an address
like hook.agentj.app/<box>/<id>, and each event goes straight to the Agent on your computer. No code to write.
Just tell your Agent, for example call me back when the video is done, set up Stripe payments or send server alerts to me. It asks what will call, what should happen when it arrives and how long it must live, then sets it up.
Pick a route first
- No cloud account or domain of your own → use the hosted webhooks (
agentj hook). This fits almost everyone. - You run your own Cloudflare / AWS / domain and want full control → have your Agent build the entry your way (a
Worker, a Lambda). To keep the same triage and notices, check the signature there and forward the event into Agent J:
on this computer with
agentj hook forward, or from the cloud as an HMAC-signed POST to a hosted hook.
Common uses
| Use | Preset | Lives | Goes to |
|---|---|---|---|
| A job you just started calls back once (video, render, transcript, crawl) | callback | once | the main Agent |
| Stripe payments, refunds, subscriptions | stripe | resident | you |
| GitHub pushes, pull requests, issues | github | resident | the main Agent |
| Server alerts and uptime monitors (Grafana, UptimeRobot, Better Stack) | alert | resident | you |
| A form or service that only takes a URL (Tally, Typeform, Apps Script) | form | resident | you |
| A cron or backup job reporting that it ran | ping | resident | you |
agentj hook presets lists every preset with its defaults.
Three lifetimes
- Once: ends with the first delivery that passes the signature check; at most 7 days.
- Time-limited: ends when it expires; 24 hours by default, at most 30 days.
- Resident: lives until you revoke it. It is a long-lived public entry, so creating one sends an approval card to your paired phone, and it only starts receiving after you approve.
The Agent creates once and time-limited hooks by itself, without a card.
When a delivery arrives
Each delivery that passes the signature check goes where the hook points and also reaches the main Agent as outside data. The Agent treats it as data only: never an instruction, never your words. It sorts it three ways:
- As expected (the video is ready, a normal payment) → it carries on with the work; with nothing to add it stays silent.
- Needs you (money, refunds, disputes, sending anything out, production changes, failures, anything unexpected) → a short report with options A / B / C and the one it recommends.
- Spam, a probe, or text trying to steer the Agent → it does none of it, records it and moves on.
Hooks that go to you (Stripe payments, server alerts) show up on your phone, and in Telegram if you use it.
On the phone
On the phone, Settings → "Agent tools" → "Webhooks" lists the live hooks (pause, resume, revoke, copy the address), today's count and "Recent deliveries" (up to 50, with the Agent's triage). Secrets are never shown on the phone.
Limits per seat
20 live hooks (10 of them resident), 1000 deliveries a day, 64 KiB per delivery. Past a limit the command says so; revoke
the hooks you no longer use (agentj hook list, agentj hook revoke <id>).
Security, honestly
- Every hook has its own secret, and signatures are checked only on your computer: Stripe, GitHub, HMAC, Bearer, or a token in the URL. A request that fails the check never reaches the Agent.
- Secrets go to a file on your computer that only you can read (0600). The commands never print them, and they never appear in the conversation. A Stripe signing secret is pasted through the key card on your phone, not into chat.
- A timestamped signature older than 5 minutes is refused, and a resent event is handled only once.
- A hosted hook's request passes through our relay's memory in the clear. The relay runs on Cloudflare and must read the request to pass it on. We commit not to read it, store it or log it, and the relay holds no hook secret. A Stripe event can carry a customer's email and the amount; with Bearer or URL-token hooks the token passes through too. For private data, use encrypted delivery (below) when the sender can shape its own request, or build your own entry on your own Cloudflare / AWS and forward it in.
- While your computer is offline the sender gets 503 and has to retry (Stripe and GitHub do). The relay does not hold events for you.
End-to-end encrypted delivery
When the sender is something you control (your server, a script, your own system), it can encrypt the body so our relay forwards only ciphertext:
agentj hook create --preset hmac --encrypt --signed ts.body --name private-orders
The hook gets its own delivery public key (shown by agentj hook show <id>; it is not a secret). The sender signs the
original body with the hook's HMAC secret as usual, encrypts it to that key with HPKE (RFC 9180: X25519, HKDF-SHA256,
ChaCha20-Poly1305, info agentj-hook-seal-v1\n<hook id>), and sends it with X-AgentJ-Seal: v1; kid=<kid>. A plain body to
such a hook is refused. Our relay still sees the address (including any query string), the headers (with the signature and timestamp), the size, the time and the sender's IP, never the body.
Services like Stripe and GitHub cannot encrypt what they send, so their hooks stay plain through the relay. Every hook says
which it is, in agentj hook list and on the phone: End-to-end encrypted or Plain through our relay. Take the public
key only from your own computer's agentj hook show, never from a message or a web page. New key pair:
agentj hook rotate <id> --enc-key (the old one still decrypts for 24 hours).
Python (pip install cryptography):
# Python 3 + `pip install cryptography`. PUB / KID / HOOK_ID from `agentj hook show <id>`, SECRET = the hook's secret file.
import base64, hashlib, hmac
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat
def _lx(suite, salt, label, ikm): return hmac.new(salt or bytes(32), b"HPKE-v1" + suite + label + ikm, hashlib.sha256).digest()
def _le(suite, prk, label, info, n):
return hmac.new(prk, n.to_bytes(2, "big") + b"HPKE-v1" + suite + label + info + b"\x01", hashlib.sha256).digest()[:n]
def seal(pub_b64u, hook_id, plaintext):
pk = base64.urlsafe_b64decode(pub_b64u + "=" * (-len(pub_b64u) % 4))
e = X25519PrivateKey.generate()
enc = e.public_key().public_bytes(Encoding.Raw, PublicFormat.Raw)
kem, suite = b"KEM\x00\x20", b"HPKE\x00\x20\x00\x01\x00\x03"
shared = _le(kem, _lx(kem, b"", b"eae_prk", e.exchange(X25519PublicKey.from_public_bytes(pk))), b"shared_secret", enc + pk, 32)
ctx = b"\x00" + _lx(suite, b"", b"psk_id_hash", b"") + _lx(suite, b"", b"info_hash", b"agentj-hook-seal-v1\n" + hook_id.encode())
s = _lx(suite, shared, b"secret", b"")
return enc + ChaCha20Poly1305(_le(suite, s, b"key", ctx, 32)).encrypt(_le(suite, s, b"base_nonce", ctx, 12), plaintext, b"")
Then send the sealed bytes with any HTTP client, for example curl:
ts=$(date +%s) # X-Signature = hex(HMAC-SHA256(secret, ts + "." + original body))
curl -X POST "$URL" --data-binary @sealed.bin -H "Content-Type: application/octet-stream" \
-H "X-AgentJ-Seal: v1; kid=$KID" -H "X-Timestamp: $ts" -H "X-Signature: $SIG"
Node 18+ (no packages):
// Node 18+, no packages. Same inputs; returns the request body (Buffer).
const c = require("node:crypto");
const lx = (suite, salt, label, ikm) => c.createHmac("sha256", salt.length ? salt : Buffer.alloc(32))
.update(Buffer.concat([Buffer.from("HPKE-v1"), suite, Buffer.from(label), ikm])).digest();
const le = (suite, prk, label, info, n) => { const l = Buffer.alloc(2); l.writeUInt16BE(n);
return c.createHmac("sha256", prk).update(Buffer.concat([l, Buffer.from("HPKE-v1"), suite, Buffer.from(label), info,
Buffer.from([1])])).digest().subarray(0, n); };
function seal(pubB64u, hookId, plaintext) {
const pk = Buffer.from(pubB64u, "base64url");
const { publicKey, privateKey } = c.generateKeyPairSync("x25519");
const enc = Buffer.from(publicKey.export({ format: "jwk" }).x, "base64url");
const dh = c.diffieHellman({ privateKey, publicKey: c.createPublicKey({ key: { kty: "OKP", crv: "X25519", x: pubB64u }, format: "jwk" }) });
const kem = Buffer.from("KEM\x00\x20", "latin1"), suite = Buffer.from("HPKE\x00\x20\x00\x01\x00\x03", "latin1");
const shared = le(kem, lx(kem, Buffer.alloc(0), "eae_prk", dh), "shared_secret", Buffer.concat([enc, pk]), 32);
const ctx = Buffer.concat([Buffer.from([0]), lx(suite, Buffer.alloc(0), "psk_id_hash", Buffer.alloc(0)),
lx(suite, Buffer.alloc(0), "info_hash", Buffer.from("agentj-hook-seal-v1\n" + hookId))]);
const s = lx(suite, shared, "secret", Buffer.alloc(0));
const ci = c.createCipheriv("chacha20-poly1305", le(suite, s, "key", ctx, 32), le(suite, s, "base_nonce", ctx, 12), { authTagLength: 16 });
return Buffer.concat([enc, ci.update(plaintext), ci.final(), ci.getAuthTag()]);
}
Examples
agentj hook create --preset callback --name video-123 --note "Video job 123 finished" --json
agentj hook create --preset stripe --name stripe-payments
agentj hook logs <id>
The first one makes a one-time callback, the second a resident Stripe hook (approve the card on your phone), the third shows what a hook has received. Every option: agentj command reference.