Bots
Bot triggers and hooks
Start a bot from outside with a signed webhook, an email address, a Slack channel, new Gmail or a Git event. Send a bot's run events out through signed hooks. Forward a webhook with no model call.
A trigger starts a bot from an event outside the platform. Every trigger hangs off a schedule of the bot (see Schedules and wake), so the run goes through the same stored policy, budget and delivery as any other run. Hooks go the other way: they send the bot's run events to a URL you own.
Inbound
| Trigger | You get | Where to set it |
|---|---|---|
| Webhook | An https URL and a secret | REST, MCP, CLI |
| An address | REST, MCP, CLI | |
| Slack channel or keyword | A bot started by a message | Studio Triggers, Slack card |
| Gmail new mail | A bot started once per new mail | Studio Triggers, Gmail card |
| GitHub or GitLab event | A webhook preset with an event allow-list | Studio Triggers, Git card (the Studio route to a webhook) |
The REST routes live under /v1/triggers/{kind}. Every call names the schedule with schedule_id: in the body for POST, in the query for GET and DELETE. Mint answers 201 with Cache-Control: no-store. A secret is in the mint response once and is never listed again. Revoking an id that is not on the schedule is 404 trigger_not_found. Webhook and email triggers also rotate: POST /v1/triggers/{webhook|email}/{token}/rotate mints a successor with the same settings and a new secret, then revokes the old token.
Webhook
curl -s -X POST https://api.plungeai.com/v1/triggers/webhook \
-H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
-d '{"schedule_id":"<id>","scheme":"github","prompt_template":"New PR: {pull_request.title}","filters":{"all":[{"path":"action","op":"equals","value":"opened"}]}}'The response carries token, secret, scheme and the url to give the sender.
| Field | Meaning |
|---|---|
scheme | How the sender signs: generic (default, X-Webhook-Signature is the hex HMAC-SHA256 of the raw body), github, gitlab or standard-webhooks. |
prompt_template | The run's input. {dotted.path} fills in a field of the JSON payload. |
filters | {all?, any?} of rules {path, op, value?}, up to 20. op is equals, contains, exists or in. A path that starts with headers. reads a request header. |
What the sender gets back, in order:
| Answer | When |
|---|---|
401 | Bad signature, or a timestamp more than 300 seconds off. |
409 | A delivery id seen in the last hour. |
204 | The filters did not match. The delivery is acknowledged, no run starts and nothing is spent. |
POST /v1/triggers/email with {"schedule_id": "..."} returns a token and an address_local_part of the form bot+<token>; the address is that local part at the Email Routing domain your operator set up. Mail to it starts the schedule's bot. allowed_from (comma-separated addresses or domains) restricts the senders; it is a sender filter, weaker than a webhook signature.
Slack channel or keyword
Answers 503 slack triggers not enabled yet until the platform owner enables it; the API documents the code.
In Studio, open the bot's Triggers and add a Slack trigger: a channel, and optionally a keyword. A human message in the channel that contains the keyword (the longest keyword wins; no keyword means every message) starts the bot with the input from <@author> in <#channel>: text. The run is signed as the trigger owner, so the bot's policy and the owner's budget apply.
- App mentions, bot posts, edits and other subtypes never match, so there is no loop. Thread replies in the channel do fire.
- Creating the trigger requires your own membership of the channel, checked server-side and fail-closed.
- The channel can be added to the job's delivery, so the result lands there.
- Anyone who can post in the channel spends the owner's budget: pick channels you trust.
Gmail new mail
In Studio, add a Gmail trigger: a Gmail search such as from:billing@ subject:invoice and a cadence of 5, 15 or 60 minutes. Studio saves a small check workflow and a heartbeat schedule. Each tick the scheduler runs the check as the owner, using the owner's own Google connection, and a YES starts the bot once per mail it has not started before, with the mail (From, Subject, Date, id and the plain-text body, up to 8,000 characters) as the run's input.
- A mail starts at most one run. Only mail that arrived after the trigger was created counts, with at most 5 runs per tick.
- A failed run is not retried, because a retry could repeat side effects. It shows in the bot's run history.
- This is a poll: the cadence is the latency, and the YES or NO check costs one small model call per tick. A tick that finds no new mail puts nothing on the board.
- Mail content is untrusted input. Choose searches you trust and keep the bot's tools narrow.
GitHub and GitLab
The Git card mints a github or gitlab webhook with an allow-list of events, then shows the URL and the secret once and where to paste them: in GitHub, repository Settings, Webhooks, Payload URL and Secret with application/json; in GitLab, Settings, Webhooks, URL and Secret token. Events outside the allow-list, including GitHub's ping, are acknowledged and ignored.
Deliver-only
A job with parameters.deliver_only: true never calls the engine or a model. When its trigger fires, the scheduler fills parameters.template ({input} is the incoming text) and posts it to the job's deliver[] targets, so a webhook can be forwarded to a channel at no model cost. The run history shows the filled text and no cost.
- Slack and Discord pings and links in the incoming text (
@everyone,<!channel>,<@U...>,<https://x|label>) are broken with an invisible character. Your own template is posted as written. - Other markup passes through, so put a
filtersallow-list on the trigger, andallowed_fromon an email trigger. - A deliver-only job needs a non-empty
deliver[]and atemplate.
Outbound hooks
Answers 503 unavailable until the platform owner enables it; the API documents the code.
A hook sends the schedule's run events to an https URL you own.
curl -s -X POST https://api.plungeai.com/v1/triggers/hooks \
-H "Authorization: Bearer $PLUNGE_API_KEY" -H 'Content-Type: application/json' \
-d '{"schedule_id":"<id>","url":"https://example.com/ocean-hook","events":["run.finished","run.failed"]}'The response carries the hook id and a secret, shown once. The URL must be https, public, with no credentials in it, and it is re-checked before every delivery. A schedule has at most 10 hooks.
| Event | When |
|---|---|
run.started | The run began. |
run.finished | The run completed. |
run.failed | The run failed, including an agent refusal that the history records as failed. |
run.needs_approval | The run stopped on a held action. |
The body is {event, delivery_id, timestamp, job_id, job_name, workflow_id, execution_id, run_id, status, result_summary?, error?}. execution_id joins one run's events.
Each delivery carries three headers: X-Ocean-Delivery-Id, X-Ocean-Timestamp (unix seconds) and X-Ocean-Signature, which is sha256= plus the hex HMAC-SHA256 of <delivery id>.<timestamp>.<raw body> under the secret. Recompute it over the raw body, compare in constant time, and reject an old timestamp:
import { createHmac, timingSafeEqual } from 'node:crypto'
export function verify(secret, headers, rawBody) {
const id = headers['x-ocean-delivery-id']
const ts = headers['x-ocean-timestamp']
if (Math.abs(Date.now() / 1000 - Number(ts)) > 300) return false
const want = 'sha256=' + createHmac('sha256', secret).update(`${id}.${ts}.${rawBody}`).digest('hex')
const got = String(headers['x-ocean-signature'] ?? '')
return got.length === want.length && timingSafeEqual(Buffer.from(got), Buffer.from(want))
}- Best effort. One retry after a second, only on a network error, a timeout (10 seconds), a 5xx, a 408 or a 429. Any other answer is final. The retry sends the identical body, id and signature, so dedupe on
X-Ocean-Delivery-Id. Redirects are never followed. A hook failure never fails or delays the run. - Scope. Scheduler-path runs only: cron, heartbeat, run now and triggers. An ad-hoc run from Studio or the API emits nothing.
- No ordering. Events are sent as they happen, each independently. Order by
timestampand join onexecution_id. - One-time jobs. An
@oncejob deletes itself when its run ends, and its hooks with it. The run's last event is still delivered.