Skip to main content

BUILD AN INTEGRATION FOR SWAMP SERVE

This guide shows you how to build an integration: an app, such as a chat bot, that people link to their Swamp account so it can run and approve workflows on a swamp serve as them. It uses Slackwater, Swamp's reference Slack app, as the worked example, and shows every call as curl, so you can build the same thing in any language.

You run the app yourself. It talks only to swamp serve, and serve talks to swamp-club, where each person signs in and agrees to what the app may do.

How an integration acts for a person

Three parties each hold one part:

Who Does Holds
An operative Registers the integration on serve with a ceiling, and grants people access to workflows Serve and its grants
A person Signs in to swamp-club and approves a link for their own account Their Swamp account
Your app Starts links, stores each person's tokens, and makes requests as that person The integration credential and people's tokens

Every request the app makes as a person is allowed only if all three of these allow it:

  • the person's own grants on serve
  • the integration's ceiling, which the operative sets
  • what the person agreed to when they linked

The app never adds to what a person can do, and an admin's blanket authority doesn't carry through it: only grants on workflows count. Serve records the person as the actor and integration:<id> as the client on every run, gate decision and audit event.

Before you start

  • Serve runs in OAuth mode, as in Set Up OAuth Authentication.
  • Serve is set up with a collective API key, as in Deploy with Headless OAuth, so its swamp-club client belongs to the collective. A serve set up through the browser device approval can't link people.
  • Your collective is on an active trial or a paid plan, any plan. A personal collective qualifies when its owner belongs to a collective that is. swamp-club refuses links for any other collective.
  • Every serve instance runs Swamp 20261111.012552.0 or later. While any live instance is older, starting and redeeming a link answer team_not_ready.
  • The workflows people will use exist on serve, with any approval gates they need. This guide uses Slackwater's demo workflow, scale-web (demo/swamp/workflows/workflow-scale-web.yaml), which plans a change, waits at a gate named approve, then applies it.

Register the integration

On the serve's repository, register the app and set its ceiling, the most any link may ever allow:

swamp access integration register chatops --name "ChatOps bot" \
  --ceiling 'run,read,approve=workflow:scale-web'
Integration: chatops
Name: ChatOps bot
State: active
Client: integration:chatops
Created: 2026-11-10T23:07:49.617Z
Ceiling: sha256:e4fcd8a23d416ccf9081b7dabca8de9180876bdc0da95982674195dcbb61d4f1
  workflow:scale-web  approve, read, run
Credential:

  swi1.chatops.…

This is the only time it is shown. Store it securely: only its hash is kept.
  • Store the credential (swi1.…) in your app's secret store now. It can't be shown again or rotated. If it's lost or leaked, revoke the integration with swamp access integration revoke chatops, which ends every link it holds, and register a new ID. A revoked ID can't be used again.
  • A ceiling names workflows: --ceiling '<actions>=workflow:<selector>', repeated for each workflow. The actions are run, read, approve and signal. A selector can end in a wildcard, but name each workflow instead: run also allows cancelling any run of that workflow, including other people's.
  • Actions in a ceiling match literally. A ceiling with run alone doesn't allow approving a gate, even though a grant with run does.
  • Add --require-email-match if you want swamp-club to refuse a link unless the account that approves has the email your app sends. Without it, a mismatch needs the person's confirmation (see Link a person).

To change the ceiling later, use swamp access integration ceiling. A wider ceiling applies to a person only after they consent again.

Grant people access

The ceiling only limits the app. Each person still needs grants on the workflows, as in Manage Access Grants:

swamp access grant create --subject collective:acme \
  --allow run,read,approve --on workflow:scale-web

To stop a person approving a run they started, write a deny with a condition, once for every subject that holds run, approve or admin on the workflow:

swamp access grant create --subject collective:acme \
  --deny approve --on workflow:scale-web \
  --when 'principal.sub == run.initiator.sub'

To keep one integration away from a workflow that a person's grants otherwise allow, deny on the client:

swamp access grant create --subject collective:acme \
  --deny run,approve --on workflow:scale-web \
  --when 'client == "integration:chatops"'

Grant changes wait for a reload unless serve runs with --grant-reload auto:

swamp access reload --server https://serve.example.com

Registering, narrowing or revoking an integration applies without a reload.

A link ties one person on your platform to their Swamp account. Each call uses the integration credential as a bearer token. In these examples it's in the SWAMP_INTEGRATION_CREDENTIAL environment variable, and serve is at https://serve.example.com.

Send who the person is on your platform, their email, and the ceiling to ask them for:

curl -X POST https://serve.example.com/api/v1/delegations/links \
  -H "Authorization: Bearer $SWAMP_INTEGRATION_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{
    "external": {
      "platform": "slack",
      "team_id": "T0001",
      "user_id": "U0ALICE",
      "display": "Alice",
      "workspace": "Acme",
      "email": "alice@example.com"
    },
    "ceiling": {
      "version": 1,
      "ceiling": [{ "actions": ["run", "read", "approve"], "resource": "workflow:scale-web" }]
    }
  }'
{
  "pendingId": "da985e72-2960-4e16-9087-77a1cbc496b3",
  "redeemSecret": "swdx1.…",
  "consentUrl": "https://swamp-club.com/link?code=…",
  "expiresIn": 600,
  "interval": 5
}
  • Always send email, the address your platform holds for the person. If someone forwards the consent URL to a colleague who approves it, the email is what shows the mismatch: swamp-club warns the colleague and asks them to confirm the account is theirs, or refuses outright under --require-email-match. Without it, only the page's wording protects the person. Slackwater reads the email with Slack's users:read.email scope.
  • platform is lowercase. team_id and user_id are your platform's stable IDs, and display and workspace are shown to the person as names. swamp-club can't check them, and says so on the page.
  • Ask for every action the app needs. A later consent replaces this one.
  • Keep pendingId and redeemSecret until the link is redeemed.
  • Starting again for the same person replaces the pending link; redeeming the old one then answers refused with reason replaced.

Send consentUrl to the person being linked, privately: a direct or ephemeral message, never a shared channel. Don't log it or echo it anywhere else. It works for 10 minutes (expiresIn), and opening it doesn't use it up, so a link preview does no harm.

The person signs in to swamp-club and sees who they're signed in as, what your app claims about them, the collective and serve, and what each action allows. Linking an app to swamp serve describes the page, and the email they get afterwards, which lets them end the link.

3. Poll until the person answers

Ask serve whether the person has answered, waiting interval seconds between calls:

curl -X POST https://serve.example.com/api/v1/delegations/redeem \
  -H "Authorization: Bearer $SWAMP_INTEGRATION_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{"pendingId": "da985e72-2960-4e16-9087-77a1cbc496b3", "redeemSecret": "swdx1.…"}'
Status Body Do
202 {"status": "pending", "interval": 5} Ask again after interval seconds
200 {"status": "issued", …} Go to the next step
200 {"status": "refused", "reason": "…"} Tell the person, and offer to start again
200 {"status": "ended", "cause": "…"} Tell the person, and offer to start again

An issued answer comes once:

{
  "status": "issued",
  "delegationId": "da985e72-2960-4e16-9087-77a1cbc496b3",
  "accessToken": "swda1.…",
  "accessTokenExpiresAt": "2026-11-11T00:11:19.600Z",
  "refreshToken": "swdr1.…",
  "expiresAt": "2026-12-10T23:11:16.298Z",
  "person": { "username": "alice", "email": "alice@example.com" }
}

A later redeem answers 409 already_redeemed. So if a redeem call times out, the tokens may have been issued and lost: end nothing, and ask the person to link again.

A person who can't approve, because the email doesn't match under --require-email-match or they aren't in the serve's collective, leaves the link pending. Your app sees refused with consent_expired when the 10 minutes run out.

4. Check who approved, then store the tokens

Compare person.email with the email you sent. If they differ, the person ticked a box saying the account is theirs and they started the link themselves, so keep the link, and tell them which Swamp account it's tied to. Slackwater names the username and email it linked, says they differ from the Slack profile's, and says how to unlink if that's wrong.

Store the tokens against the platform user who started the link, encrypted, and never let one person's tokens act for another.

Linking again

When the same person consents again for the same platform account, the new consent replaces what they agreed to, wider or narrower, and keeps the delegationId, so runs started under it carry on. The 30 days start again. Redeeming the new link issues fresh tokens and retires the old ones; until then the old tokens keep working.

Act for the person

Every action is a POST to /api/v1/action with that person's access token. Serve applies their grants, the ceiling and their consent, and records them as the actor:

curl -X POST https://serve.example.com/api/v1/action \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "workflow.run",
    "payload": {
      "workflowIdOrName": "scale-web",
      "idempotencyKey": "slack-trigger-8723",
      "inputs": { "count": 3, "reason": "traffic" }
    }
  }'
{
  "requestId": "25be8e13-2ea7-48fd-bc48-aaaa1609d11e",
  "type": "workflow.run",
  "data": {
    "runId": "eb9eea1c-80fd-439d-aee3-1ce7f6e80612",
    "workflowName": "scale-web",
    "replayed": false
  }
}

The route takes a bearer token only, never a cookie, and these types:

Type Payload Needs in the ceiling
workflow.run workflowIdOrName, idempotencyKey, inputs run
workflow.resume workflowIdOrName, runId, inputs run
workflow.history.get runId read
workflow.approvals {} read
workflow.approve workflowIdOrName, runId, stepName, suspendedAt, reason, jobName approve
workflow.reject the same as workflow.approve approve
workflow.signal waitId, payload, idempotencyKey, or workflow and key for waitId signal

Payloads are strict: an unknown key answers 400 invalid_request.

Start a run once per user action

idempotencyKey is required on workflow.run. Make it from the user's action, such as Slack's trigger or form ID, using letters, digits and ._:-, at most 128 characters. Sending the same key with the same inputs returns the same run with "replayed": true, so a retry never starts a second run. The same key with different inputs answers 409 idempotency_conflict.

Decide a gate

List the gates the person can see, then send the gate's suspendedAt back with the decision:

curl -X POST https://serve.example.com/api/v1/action \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"type": "workflow.approvals", "payload": {}}'
{
  "requestId": "e677bf72-8c88-4e0a-8930-2924ddb44744",
  "type": "workflow.approvals",
  "data": {
    "approvals": [
      {
        "workflowName": "scale-web",
        "runId": "eb9eea1c-80fd-439d-aee3-1ce7f6e80612",
        "stepName": "approve",
        "suspendedAt": "2026-11-10T23:11:29.573Z",
        "prompt": "Scale web-demo to 3 servers? Reason: traffic",
        "initiatedBy": "user:6af3a357572a4b7820677e54",
        "canDecide": false,
        "canDecideReason": "grant-condition"
      }
    ],
    "expired": []
  }
}
curl -X POST https://serve.example.com/api/v1/action \
  -H "Authorization: Bearer $ACCESS_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "type": "workflow.approve",
    "payload": {
      "workflowIdOrName": "scale-web",
      "runId": "eb9eea1c-80fd-439d-aee3-1ce7f6e80612",
      "stepName": "approve",
      "suspendedAt": "2026-11-10T23:11:29.573Z",
      "reason": "looks right"
    }
  }'
{
  "requestId": "13b1b38b-965b-46a2-a9f7-0f0fa8d6a2cf",
  "type": "workflow.approve",
  "data": {
    "runId": "eb9eea1c-80fd-439d-aee3-1ce7f6e80612",
    "workflowName": "scale-web",
    "stepName": "approve",
    "approved": true,
    "decidedBy": "user:6af3a357572a4b7820677e59",
    "client": "integration:chatops",
    "reason": "looks right",
    "allGatesDecided": true,
    "autoResumed": true
  }
}

canDecide is serve's view of whether this person may decide the gate; here the self-approval deny refuses the person who started the run. suspendedAt means a click on an old message can't decide a gate that has since been asked again: serve answers 409 gate_reopened, and a second decision answers 409 already_decided. If a step name appears in more than one job, send jobName too.

Runs outlive the token

A run started through a link is bound to the link, not to the access token that started it, so refreshing tokens never cuts it off. Once the link stops standing, serve refuses any resume of the run before a step runs, but a step already running carries on.

Anyone whose link allows run on the workflow can resume a run someone else started, but the resumed steps use a vault only if both of them may use it, and only the person who started the run, or an access admin, can send inputs with the resume. Anyone else gets 403 resume_inputs_refused and the run stays suspended. An admin acting through an integration isn't an admin here. So if the person resuming isn't the one who started the run, don't send inputs, and if serve refuses, explain why rather than retrying.

A run waiting at an approval gate continues once someone decides the gate, so decide it rather than resuming the run. A resume of a run whose gate is still open answers 409 awaiting_approval, with the gate in gate as job and step, which workflow.approve takes as jobName and stepName. Releases before Swamp 20261111.161908.0 answer 500 action_failed instead.

The action route doesn't take workflow.cancel. A ceiling with run still allows cancelling any run of the workflow over serve's other transports, and an operative can keep that from every integration with --restricted-commands workflow.cancel.

Keep the tokens fresh

Token Form Lasts
Access swda1.… 1 hour, and never past the link's end
Refresh swdr1.… Until the link ends. It never changes
The link — 30 days from the person's latest consent (expiresAt)

Refresh before the access token runs out, with the integration credential and the refresh token:

curl -X POST https://serve.example.com/api/v1/delegations/refresh \
  -H "Authorization: Bearer $SWAMP_INTEGRATION_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{"refreshToken": "swdr1.…"}'
{
  "status": "refreshed",
  "delegationId": "da985e72-2960-4e16-9087-77a1cbc496b3",
  "accessToken": "swda1.…",
  "accessTokenExpiresAt": "2026-11-11T00:12:35.362Z",
  "expiresIn": 3600,
  "expiresAt": "2026-12-10T23:11:16.298Z",
  "person": { "username": "alice", "email": "alice@example.com" }
}
  • Keep your refresh token: it isn't returned again.
  • The new access token and the two before it each work until they expire, so two copies of your app refreshing at once do no harm.
  • Use accessTokenExpiresAt, not expiresIn, which is always 3600 even when the token is cut short at the link's end.
  • Refreshing never extends the link. After 30 days the person links again.

A refused refresh is 401 with code: invalid_grant and a reason. Three reasons mean the link is paused and comes back on its own: keep the tokens, tell the person, and try again later. Any other reason means the link is over.

Reason Means Do
not_admitted The person isn't admitted to serve now, for example they left the collective Keep the tokens, tell the person, try later
suspended swamp-club has suspended the person's account Keep the tokens, tell the person, try later
links_unavailable Serve can't confirm the link with swamp-club right now Keep the tokens, tell the person, try later
not_issued The link has ended, by any route Discard the tokens, offer to link again
expired The 30 days are over Discard the tokens, offer to link again
superseded A newer consent for this person replaced it Discard the tokens, use the newer link
unknown Serve doesn't know this refresh token Discard the tokens, offer to link again
mismatch The refresh token is wrong for this link Check your storage; nothing has ended
unreadable Serve can't read the link's record Tell the person, and the operative

When the person unlinks in your app, end the link with either of its tokens:

curl -X POST https://serve.example.com/api/v1/delegations/end \
  -H "Authorization: Bearer $SWAMP_INTEGRATION_CREDENTIAL" \
  -H "Content-Type: application/json" \
  -d '{"token": "swdr1.…"}'
{ "ended": true }

A 401 invalid_grant here means the link had already ended. Then discard the tokens either way.

A link also ends without your app:

  • the person ends it from the link email, or deletes their account
  • an operative runs swamp access delegation end or revokes the integration
  • swamp-club can't deliver the link email
  • the collective's OAuth client is deleted, or its secret is rotated with links ended
  • the collective's trial ends or its paid plan stops, recorded as plan_lapsed; the links stay ended if it pays again later, so people link again

Serve hears about an end on swamp-club within about 30 seconds. After that, the person's access token answers 401 and refresh answers not_issued. When swamp-club refuses serve's own client, because it was deleted or its secret rotated, links pause at once (links_unavailable) and end after an hour unless serve is given a working client.

What your app must do

Serve can't check these, so your app has to:

  • Keep secrets secret. Store the integration credential, refresh tokens and access tokens encrypted, and never log them, show them or put them in an error.
  • Bind tokens to the person who linked. Key the tokens on the platform user who started the link, and never let one person's request use another's tokens.
  • Show the consent URL only to that person, privately, and never log it.
  • Send the person's email when starting a link, and tell them when a different account approved.
  • Make idempotency keys from the user's action, not from a timer or a counter, so a retry of the same click replays.
  • Keep the ceiling narrow. Name each workflow rather than using a wildcard.
  • Treat display names as unchecked. Serve shows what your app sends.
  • Back off when refused. Honour Retry-After on 429 and 503, and stop using a token serve has refused. Failed attempts with a link's tokens count against that link from your app's address, and after five serve refuses it for a while. Failed redeems and refreshes count against the whole integration, so retrying them can hold up other people's links.

Known limits

  • A compromised app can act for everyone linked to it, within each link's consent and the ceiling, until the integration is revoked. Revoking ends every link at once.
  • Work already started outlives the link. A step running when a link ends finishes, though nothing resumes the run afterwards through that link.
  • A run uses the workflow's vaults. Allowing run on a workflow lets the person cause it to use whatever vaults it touches.
  • Ending a link takes effect within about 30 seconds, not at once, on other instances and when the end comes from swamp-club.
  • A signal continues a run with its starter's access. Allowing signal on a workflow lets the person send a payload that a run someone else started acts on, as that person.

Refusals and what to do

Every error is JSON with a code and a message. Most action-route errors add a requestId to quote when reporting a problem.

Code Status Means Do
invalid_client 401 The integration credential is wrong, or the integration was revoked Stop and tell the operative
invalid_request 400 The body is malformed, for example an uppercase platform or a name over 80 characters Fix the request
email_required 400 The integration requires an email and you sent none Send the person's email
invalid_ceiling 400 The ceiling isn't in the format above Fix the ceiling
ceiling_too_wide 400 You asked for more than the integration's ceiling, or it isn't active Ask for less, or have the operative widen it
too_many_pending 429 50 of this integration's links are waiting for consent Wait for some to finish or expire
team_not_ready 409 A serve instance runs an older release Try again after the upgrade
links_unavailable 503 Serve isn't in OAuth mode with a swamp-club client Tell the operative
links_require_plan 409 The collective isn't on an active trial or a paid plan Show the person the message, which says what's needed, and tell the operative
links_not_enabled 409 swamp-club has turned links off for now Try again later
no_instance 409 swamp-club has no record of this serve running Tell the operative
club_refused 400 swamp-club refused the request; the message names its reason Tell the operative
club_busy 503 swamp-club is limiting requests Try again shortly
links_require_collective_client 409 Serve was set up through the browser device approval, so its swamp-club client isn't a collective's Tell the operative to set serve up with a collective API key
club_unavailable 502 swamp-club couldn't be reached, or refused serve's own client for another reason Try again later; if it lasts, tell the operative

Redeeming

Answer Means Do
refused, consent_denied The person denied it Accept it
refused, consent_expired Nobody approved within 10 minutes, including a person the page wouldn't let approve Offer to start again
refused, not_admitted The person who approved isn't admitted to serve Tell them to ask the operative
refused, replaced You started a newer link for the same person Poll the newer one
refused, integration_revoked The integration was revoked Stop and tell the operative
refused, other reasons swamp-club or serve couldn't complete the link Offer to start again
ended, not_redeemed Tokens weren't collected within an hour of approval Offer to start again
409 already_redeemed The tokens were already handed out Use the ones you stored, or relink
404 not_found Serve doesn't know this pendingId and secret Stop polling it
401 invalid_grant, with reason A relink whose link is paused (suspended, links_unavailable) Keep polling

Acting

Code Status Means Do
unauthorized 401 The access token expired, was replaced, or the link isn't standing Refresh once and retry; the refresh reason says which
forbidden 403 The person's grants, the ceiling or a deny refuse it Tell the person they can't; don't retry
consent_required 403 The person's grants allow it, but they didn't agree to it when linking Offer to link again
resume_inputs_refused 403 Only the person who started the run, or an access admin, can send inputs with a resume Explain that the starter must resume it; don't retry
not_found 404 No such workflow, run or gate, or one the person can't see Tell the person
type_not_allowed 404 The route doesn't take this type Use a listed type
invalid_request 400 The payload or the workflow's inputs are wrong Fix the request
idempotency_conflict 409 This key was used with different inputs Use a new key for a new action
already_decided 409 Someone already decided the gate Show the outcome
gate_reopened 409 The gate was asked again since you read it Read the approvals again
gate_closed 409 The gate expired or closed Show that it's closed
gate_not_open 409 The run hasn't reached the gate Wait, then read the approvals again
run_not_resumable 409 The run can't be resumed now, for example it's waiting on another run Read the run's history, then try later
awaiting_approval 409 The run is waiting at an approval gate, named in gate Decide the gate instead of resuming
ambiguous_gate 409 The step name is in more than one job Send jobName
busy 409 The run is busy Retry after Retry-After
rate_limited 429 Too many requests, or too many failed ones Wait for Retry-After
too_many_runs 429 Serve's run limit is reached Wait for Retry-After
still_working 503 No answer within 30 seconds; the work carries on Retry with the same idempotency key, or check the run
outcome_unknown 503 Serve can't confirm whether it happened Check the run before retrying
shutting_down, not_ready, serve_not_leader, audit_unavailable, access_unavailable, leader_upgrade_required, runs_unavailable 503 Serve can't take the request now Retry after Retry-After
action_failed 500 The action failed on serve Tell the person, with the requestId

When serve doesn't answer

Give every call a timeout. A call that fails without an answer may still have happened: retry a run with the same idempotency key, read the approvals again before re-sending a decision, and don't assume a redeem failed, since its tokens are handed out once.

Slackwater as the reference

Slackwater does all of this for Slack, in TypeScript under the Apache 2.0 licence. Every call it makes to serve is in one module, src/serve/client.ts: port that file to build the same app in another language.

Step Slackwater
Start a link ServeClient.startLink, called from /slackwater link in src/app.ts
Show the consent URL consentPrompt in src/messages.ts, sent as an ephemeral reply
Poll and store ServeClient.redeem; the app polls at interval and stores the tokens in src/store.ts
Check who approved The email comparison after redeem in src/app.ts
Run a workflow ServeClient.runWorkflow, with Slack's trigger or form ID as the idempotency key
Decide a gate ServeClient.listApprovals, then approve or reject with the gate's suspendedAt
Refresh TokenKeeper in src/tokens.ts, which keeps the tokens on a refusal the link recovers from
End ServeClient.end, from /slackwater unlink

Its store, EncryptedFileStore in src/store.ts, encrypts every token at rest, and the Secret type in src/serve/secret.ts keeps tokens out of logs and JSON.

To run it beside serve, follow its README: register the integration and grants as above, create the Slack app from a manifest built from your commands file (the demo's is demo/manifest.json), put the credential and Slack's tokens in .env, and start it with deno task start. It uses Slack's Socket Mode, so it needs no public address: it connects out to Slack and to serve.