# Integrations

> Connect GitHub, Slack, Stripe and seven other services so agents in a space can call them through the gateway without ever holding the real key.

moatbox runs coding agents (Claude Code, Codex, opencode) inside spaces: Firecracker microVMs with their own disk. A gateway inside each space sits between the agent and the network. An integration teaches that gateway one outside service: which hosts it lives on, which credential it needs, and which calls are risky enough to wait for a person.

This page covers how integrations work in general, how you connect one, and what each of the ten built-in integrations holds by default. The hold strategies (`pass`, `approve-only`, `compensate`, `checkpoint`, `hold`) are defined in [Approvals](https://docs.moatbox.dev/approvals.md).

## How an integration works

1. You paste a key for the service on the **Integrations** page of the dashboard. moatbox encrypts it and shows only its last 4 characters from then on.
2. moatbox delivers the key to the gateway of every space it applies to. The agent never receives it.
3. The agent gets a placeholder instead, in the environment variable the service's tools expect (for example `STRIPE_API_KEY=moatbox-ph-<32 hex characters>`).
4. The space trusts a moatbox certificate authority, so the gateway can read HTTPS requests to the integration's hosts. When a request leaves for one of those hosts, the gateway swaps the placeholder for the real credential. It does this at send time, so a request that waited for approval still goes out with a current token.
5. Before it sends, the gateway classifies the request. Risky calls wait for approval, some of them with an undo recorded. Calls the integration does not list go through.

If the placeholder shows up in a request to any other host the gateway can read, the gateway refuses it with `403 moatbox: credential sent to wrong host` and logs a `credential.wrong-host` event.

When a space's network is restricted, a connected integration's hosts are allowed for as long as the integration stays connected. The space's **Network** page lists them under **Connected integrations**.

## Connect an integration

1. Open **Integrations** in the dashboard sidebar. Services you have not connected appear under **Connect a service** (or **Available** once you have connected one).
2. Click a service. Most open a guided form: step 1 is a button such as **Open GitHub** that opens the service with the key's name and permissions filled in, step 2 is where you paste the key.
3. Under **Saved as**, rename the key if you like and pick which spaces get it: **every space** or **only `<space>`**.
4. Click **Save and test**.

The dashboard checks the key's shape before it saves. It recognizes token types by their prefix and refuses the types that reach more than an agent needs or expire within hours, and says why: a classic GitHub token (`ghp_…`), a Slack user token (`xoxp-…`), a Cloudflare Global API Key, a Gmail access token (`ya29.…`).

After saving, the dashboard reports **Delivered to every running space**, or names the spaces it has not reached yet and retries every few seconds.

Each saved key has three buttons:

- **Test** asks a space to call the service with the credential that space uses, and shows who the service says you are (for GitHub: the login, how many repositories the token reaches, and when it expires). If the space has its own environment variable or OAuth file for the credential, the test uses that and the result says it overrides the key. A sleeping space wakes first. The test gives up after 20 seconds.
- **Rename** changes the key's label.
- **Remove** takes the key out of every space it reached. The key itself keeps working in the service until you delete it there.

Who can do what:

- Owners and admins set keys for every space. Members can set a key for a single space.
- A key set for one space replaces the team key in that space.
- An environment can limit which integrations reach its spaces, or turn them all off. See [Environments](https://docs.moatbox.dev/environments.md).
- CI spaces receive no integration keys. See [GitHub Actions runners](https://docs.moatbox.dev/ci.md).

The form warns: root in a Full machine space can read the key.

### Set a key through the API

The dashboard calls the same endpoint you can call with an API key:

```bash
curl -X PUT https://app.moatbox.dev/api/v1/teams/<team-id>/integrations/stripe \
  -H "Authorization: Bearer mbx_xxx" \
  -H "Content-Type: application/json" \
  -d '{"value": "<your-restricted-key>", "space": "my-space", "name": "moatbox my-space"}'
```

Leave `space` empty to set the key for every space. AWS and Gmail take named `fields` instead of `value` (listed in their sections below). The answer names the key's kind, its last 4 characters, and `pending`, the spaces that have not received it yet. `DELETE` on the same path removes a key (add `?space=<slug>` for a space's own key), and `POST /api/v1/spaces/<space>/integrations/<name>/test` runs the test.

An API key without the `environment.write` permission can only set a key for one named space. See [API keys](https://docs.moatbox.dev/api-keys.md).

## Which processes get the placeholder

- **GitHub** is signed in for every shell in a space that has a GitHub key: `gh` and `git` work through the gateway in each new terminal.
- **Any integration** reaches an agent run when the run lists it in `broker`:

```bash
curl -X POST https://app.moatbox.dev/api/v1/spaces/my-space/runs \
  -H "Authorization: Bearer mbx_xxx" \
  -H "Content-Type: application/json" \
  -d '{"agent": "claude", "task": "Refund the duplicate charge on the Acme invoice", "broker": ["stripe", "github"]}'
```

A run that names an integration with no key set fails to start, with an error naming the missing credential.

The dashboard's start form sends no `broker` list, so runs started from the dashboard get no integration credentials. Start the run through the API with `broker` when the agent needs one.

## Rules: what gets held

The **Default rule** section of each integration's dialog sets how strict the gateway is. You can also give a single key its own rule.

- **Hold risky actions** (the default) holds the actions listed for each integration below.
- **Hold every action** holds every action the integration matches, including the ones it would let through, and every other request except `GET`, `HEAD` and `OPTIONS`. Actions that would send after a window wait for a person instead.
- **Auto-approve listed actions** lets the action kinds you list (such as `slack.post`) go through without asking, when their strategy is `hold` or `approve-only`. Listed kinds with `checkpoint` or `compensate` still wait. Everything else is held as under **Hold risky actions**.

Members can make a rule stricter. Only owners and admins can loosen one. [Approvals](https://docs.moatbox.dev/approvals.md) covers approving, denying and undoing.

## Limits that apply to every integration

On an integration's hosts the gateway refuses:

- a request carrying a method override header or parameter (`X-HTTP-Method-Override`, `_method` and similar),
- a protocol upgrade, such as a WebSocket,
- a path with `.` or `..` segments, or empty segments outside an S3 key.

A request body over 1 MiB is too large for the gateway to read. It is held for approval as `<integration>.uninspected` unless the matched action already records an undo or waits for a person.

## Integrations

Each section lists the hosts where the gateway swaps the credential, how to connect, the variable that holds the placeholder, and what **Hold risky actions** does. The **Kind** column is the name shown in approvals and the audit log.

"Recorded" means the call goes through and leaves an entry in the audit log. Calls not listed go through without an entry.

### GitHub

- **Hosts:** `github.com`, `api.github.com`
- **Placeholder:** `GITHUB_TOKEN` and `GH_TOKEN`. git is configured to send `git@github.com:` remotes over HTTPS and to use the placeholder as its password.
- **Connect:** **Open GitHub** creates a fine-grained token (`github_pat_…`) with a 90-day expiry and write access to contents, pull requests and issues. You pick the owner and the repositories. Or run `moatbox github connect <space>` on your laptop: it stores the token `gh` is signed in with (`gho_…`) as that space's key and tests it.

| Action | Kind | Strategy | Undo |
|---|---|---|---|
| git push that rewrites a branch (force push) | `github.force-push` | compensate | Pushes the old commit back. GitHub refuses if anyone pushed since. |
| git push that deletes a branch or tag | `github.push-delete` | compensate | Recreates it at the old commit. |
| git push moatbox cannot read | `github.push` | approve-only | No |
| Force update of a ref through the REST API | `github.ref-force-update` | compensate | Pushes the old commit back. |
| Delete a ref through the REST API | `github.ref-delete` | approve-only | No |
| Open a pull request moatbox cannot read | `github.pr-create` | approve-only | No |
| Delete a repository | `github.repo-delete` | approve-only | No |
| Change repository settings, deploy keys, collaborators, webhooks, invitations, Actions runners, or transfer the repository | `github.access` | approve-only | No |
| Change organization webhooks, members, invitations, teams or runners, team endpoints, or add an SSH key to the account | `github.access` | approve-only | No |
| GraphQL `deleteRef`, `updateRef`, `updateRefs` | `github.graphql-ref` | approve-only | No |
| Any other GraphQL mutation, or one moatbox cannot read | `github.graphql` | approve-only | No |
| Any other REST write | `github.write` | approve-only | No |

Recorded: pushes that create refs or fast-forward, ref updates without `force`, opening pull requests and issues, updating or labelling issues and pull requests, comments and reviews, and the GraphQL mutations `createPullRequest`, `createIssue`, `addComment`, `addPullRequestReview`, `addPullRequestReviewComment`, `addPullRequestReviewThread` and `submitPullRequestReview`.

git does not tell the server a push was forced. The gateway reads the pushed commits and treats a push as a fast-forward only when it can trace the new commit back to the old one. When it cannot, it holds the push.

### Slack

- **Hosts:** `slack.com`
- **Placeholder:** `SLACK_TOKEN`
- **Connect:** **Open Slack** creates a Slack app with its name and permissions filled in. Install it to the workspace, paste the **Bot User OAuth Token** (`xoxb-…`), and invite the bot to each channel with `/invite`. User, app-level, rotating and browser tokens are refused.

| Action | Kind | Strategy | Undo |
|---|---|---|---|
| `chat.postMessage`, `chat.meMessage` | `slack.post` | hold, 30 s window | No |
| `chat.update` | `slack.update` | hold, 30 s window | No |
| `chat.scheduleMessage` | `slack.schedule` | hold, 30 s window | No |
| `chat.postEphemeral` | `slack.ephemeral` | hold, 30 s window | No |
| `files.upload`, `files.completeUploadExternal`, `files.share` | `slack.file-share` | hold, 30 s window | No |
| `chat.delete` | `slack.delete` | approve-only | No |
| `conversations.archive` | `slack.archive` | approve-only | No |
| `conversations.kick` | `slack.kick` | approve-only | No |
| `files.delete` | `slack.file-delete` | approve-only | No |
| Any `admin.*` method | `slack.admin` | approve-only | No |
| Any method moatbox does not know, or arguments it cannot read | `slack.call` | approve-only | No |

A 30-second window means the message sends after 30 seconds unless someone cancels it. Approving sends it at once.

Recorded as `slack.call`: `search.*`, methods whose last part starts with `get` or is `list`, `info`, `history`, `replies`, `members`, `test` or `lookupByEmail`, and `reactions.add`, `reactions.remove`, `conversations.open`, `conversations.join`, `conversations.mark`.

### Discord

- **Hosts:** `discord.com`
- **Placeholder:** `DISCORD_BOT_TOKEN`
- **Connect:** **Open Discord** opens the Developer Portal. Create an application, turn **Public Bot** off, turn on **Message Content Intent** if the agent should read messages, click **Reset Token**, and paste the bot token. Step 3, **Invite the bot**, adds it to your server with permissions to view channels, read history, send messages, embed links and attach files.

| Action | Kind | Strategy | Undo |
|---|---|---|---|
| Post a message | `discord.message-create` | compensate | Deletes the message. |
| Post a message moatbox cannot read | `discord.message-create` | approve-only | No |
| Edit a message's content, embeds, components or flags | `discord.message-edit` | compensate | Restores the old values. Refused if the message changed since. |
| Edit with attachments or components v2, or an edit moatbox cannot read | `discord.message-edit` | approve-only | No |
| Delete a message | `discord.message-delete` | approve-only | No |
| Bulk-delete messages | `discord.message-bulk-delete` | approve-only | No |
| Delete a channel | `discord.channel-delete` | approve-only | No |
| Change a channel's permission overwrites | `discord.channel-permissions` | approve-only | No |
| Any webhook endpoint | `discord.webhook` | approve-only | No |
| Delete the server | `discord.guild-delete` | approve-only | No |
| Ban or bulk-ban | `discord.ban` | approve-only | No |
| Kick a member or prune members | `discord.kick` | approve-only | No |
| Add, remove or set a member's roles | `discord.member-roles` | approve-only | No |
| Create, reorder, modify or delete roles | `discord.role-change` | approve-only | No |

Recorded: message edits that touch none of content, embeds, components or flags.

### Gmail

- **Hosts:** `gmail.googleapis.com`
- **Placeholder:** `GMAIL_ACCESS_TOKEN`. The gateway trades your refresh token for short-lived access tokens. The agent sees neither.
- **Connect:** You bring your own Google OAuth client. **Open Google Cloud**, enable the Gmail API, add yourself as a test user, and create a Web application client with `https://developers.google.com/oauthplayground` as a redirect URI. In **the OAuth Playground**, use your own client, authorize `https://www.googleapis.com/auth/gmail.modify`, exchange the code, and paste the client ID, client secret and refresh token. While the app's consent screen has the External user type and the Testing publishing status, Google issues refresh tokens that expire after 7 days. Move the app to In production before you create the refresh token to avoid that limit.
- **API fields:** `client_id`, `client_secret`, `refresh_token`.

| Action | Kind | Strategy | Undo |
|---|---|---|---|
| Send a message | `gmail.send` | hold, no window | No |
| Send a draft | `gmail.draft-send` | hold, no window | No |
| Move a message or thread to the trash | `gmail.trash`, `gmail.thread-trash` | compensate | Takes it out of the trash. |
| Permanently delete a message or thread | `gmail.delete`, `gmail.thread-delete` | approve-only | No |
| Delete a draft | `gmail.draft-delete` | approve-only | No |
| `messages/batchDelete` | `gmail.batch-delete` | approve-only | No |
| Create or delete a mail filter | `gmail.filter-create`, `gmail.filter-delete` | approve-only | No |
| Add or remove a forwarding address | `gmail.forwarding-create`, `gmail.forwarding-delete` | approve-only | No |
| Change auto-forwarding | `gmail.auto-forwarding` | approve-only | No |
| A batch request | `gmail.batch` | approve-only | No |
| Any other delete | `gmail.delete` | approve-only | No |

A hold with no window waits for a person, as `approve-only` does.

### Stripe

- **Hosts:** `api.stripe.com`
- **Placeholder:** `STRIPE_API_KEY`
- **Connect:** Pick **Test** or **Live**, then **Open Stripe** creates a restricted key (`rk_…`) with its permissions filled in. It can manage customers, products, prices, coupons, promotion codes, payment links, checkout, the customer portal, invoices, subscriptions and webhooks, take payments and refunds, and read the balance, payouts, transfers, disputes and tax rates. A full secret key (`sk_…`) works, with a warning. A publishable key (`pk_…`) is refused.

| Action | Kind | Strategy | Undo |
|---|---|---|---|
| Any `DELETE` | `stripe.delete` | approve-only | No |
| Create a charge | `stripe.charge` | approve-only | No |
| Capture a charge | `stripe.charge-capture` | approve-only | No |
| Create a payment intent with `confirm=true`, or confirm one | `stripe.payment-intent-confirm` | approve-only | No |
| Capture a payment intent | `stripe.payment-intent-capture` | approve-only | No |
| Refund | `stripe.refund` | approve-only | No |
| Payout | `stripe.payout` | approve-only | No |
| Transfer, or reverse a transfer | `stripe.transfer`, `stripe.transfer-reversal` | approve-only | No |
| Top up the balance | `stripe.topup` | approve-only | No |
| Pay an invoice, or mark it paid outside Stripe | `stripe.invoice-pay` | approve-only | No |
| Create or update a subscription | `stripe.subscription`, `stripe.subscription-update` | approve-only | No |
| Issue a credit note | `stripe.credit-note` | approve-only | No |
| Update a customer, product or price | `stripe.customer-update`, `stripe.product-update`, `stripe.price-update` | compensate | Restores the old values. Refused if a field the update touched changed since. |
| Update a customer that sets a card, bank account or source | `stripe.customer-update` | approve-only | No |
| Any other `POST` | `stripe.write` | approve-only | No |

These creates go through: customers, products, prices, coupons, promotion codes, checkout sessions, customer portal sessions, payment links, setup intents, and payment intents without `confirm=true`.

### Cloudflare

- **Hosts:** `api.cloudflare.com`
- **Placeholder:** `CLOUDFLARE_API_TOKEN`
- **Connect:** **Open Cloudflare** starts an account API token with its name and permissions filled in. Limit the zone resources to the zones the agent should touch, create the token (`cfat_…`), and paste it. User tokens (`cfut_…`) and older 40-character tokens also work. The Global API Key, Origin CA keys and Wrangler OAuth tokens are refused.

| Action | Kind | Strategy | Undo |
|---|---|---|---|
| Delete a zone | `cloudflare.zone-delete` | approve-only | No |
| Delete a zone setting | `cloudflare.zone-setting-delete` | approve-only | No |
| Batch or import DNS records | `cloudflare.dns-batch`, `cloudflare.dns-import` | approve-only | No |
| Update a DNS record | `cloudflare.dns-update` | compensate | Restores the old record. Refused if the record changed since. |
| Delete a DNS record | `cloudflare.dns-delete` | compensate | Recreates the record. |
| Delete an R2 bucket | `cloudflare.r2-bucket-delete` | approve-only | No |
| Upload a Worker | `cloudflare.worker-upload` | compensate | Redeploys the previous versions, or deletes a Worker that did not exist before. Refused if the Worker was deployed again since. |
| Change a Worker's deployment | `cloudflare.worker-deploy` | compensate | Redeploys the previous versions. |
| Delete a Worker | `cloudflare.worker-delete` | approve-only | No |
| Any other `DELETE` | `cloudflare.delete` | approve-only | No |

A DNS update the gateway cannot read is held as `approve-only`.

### Hetzner

- **Hosts:** `api.hetzner.cloud`
- **Placeholder:** `HCLOUD_TOKEN`
- **Connect:** **Open Hetzner** opens the Hetzner Console. A token reaches one whole project, so use a project made for the agent. Under Security, API tokens, generate a Read & Write token and paste it (64 letters and digits). Tokens from the old DNS Console are refused.

| Action | Kind | Strategy | Undo |
|---|---|---|---|
| Create a server | `hetzner.server-create` | compensate | Deletes the server. |
| Delete a server | `hetzner.server-delete` | approve-only | No |
| Rebuild a server (wipes its disk) | `hetzner.server-rebuild` | approve-only | No |
| Any other write | `hetzner.write` | approve-only | No |

A server create the gateway cannot read is held as `approve-only`.

### Linear

- **Hosts:** `api.linear.app`
- **Placeholder:** `LINEAR_API_KEY`
- **Connect:** **Open Linear** opens Security & access. Create a personal API key (`lin_api_…`) with only the permissions the agent needs and limit it to its teams. The key acts as you, so its changes show your name. OAuth tokens (`lin_oauth_…`) are refused.

| Action | Kind | Strategy | Undo |
|---|---|---|---|
| `issueUpdate` that changes the state | `linear.issue-state` | compensate | Restores the old values. Refused if a changed field moved since. |
| `issueUpdate` of title, description, priority, estimate, due date, labels, assignee, project, cycle, parent, team and similar fields | `linear.issue-update` | checkpoint | Restores the old values. Refused if a changed field moved since. |
| `issueUpdate` with a field moatbox cannot restore, or one it cannot read | `linear.issue-update` | approve-only | No |
| `issueDelete` | `linear.issue-delete` | approve-only | No |
| `projectDelete` | `linear.project-delete` | approve-only | No |
| `issueBatchUpdate` | `linear.issue-batch-update` | approve-only | No |
| Mutations starting with `team` | `linear.team-change` | approve-only | No |
| Mutations starting with `workflowState` | `linear.workflow-change` | approve-only | No |
| Any other mutation ending in `Delete` or `Archive` | `linear.delete` | approve-only | No |
| Several mutations in one request, if any of them would be held | `linear.mutation` | approve-only | No |
| A mutation moatbox cannot read | `linear.mutation` | approve-only | No |

Recorded as `linear.mutation`: every other mutation, such as creating issues and comments.

### Notion

- **Hosts:** `api.notion.com`
- **Placeholder:** `NOTION_TOKEN`
- **Connect:** **Open Notion** opens your connections (a workspace owner has to do this). Create an internal connection, keep the content and comment capabilities, add only the pages the agent may use under Content access, and paste the Internal Integration Secret (`ntn_…`, or `secret_…` for older ones). The connection sees only pages you share with it.

| Action | Kind | Strategy | Undo |
|---|---|---|---|
| Update a page or block | `notion.page-update`, `notion.block-update` | checkpoint | Writes back the old values. |
| Move a page | `notion.page-move` | checkpoint | Moves it back. A page whose old parent is not a page or data source can't be moved back, so the approved move fails and the page stays put. |
| Append blocks | `notion.block-append` | compensate | Deletes the added blocks. |
| Delete a block | `notion.block-delete` | compensate | Restores it from the trash. |
| Move a page, block, database or data source to the trash | `notion.page-trash`, `notion.block-trash`, `notion.database-trash`, `notion.data-source-trash` | compensate | Restores it from the trash. |
| Update a page with `erase_content` or a template | `notion.page-content` | approve-only | No |
| Rewrite a page from markdown | `notion.page-markdown` | approve-only | No |
| Update a database or data source | `notion.database-update`, `notion.data-source-update` | approve-only | No |
| Delete a comment or view | `notion.comment-delete`, `notion.view-delete` | approve-only | No |
| An update moatbox cannot read | `notion.<object>-update` | approve-only | No |

Recorded: creating pages, adding comments, and restoring from the trash.

### AWS

- **Hosts:** `s3.amazonaws.com`, `ec2.amazonaws.com`, `iam.amazonaws.com`, `sts.amazonaws.com`, plus the regional S3, EC2, RDS and STS endpoints (`s3.<region>.amazonaws.com` and so on) in each region the dashboard offers.
- **Placeholder:** `AWS_ACCESS_KEY_ID=AKIAMOATBOXEXAMPLE00` and a placeholder `AWS_SECRET_ACCESS_KEY`. `AWS_REGION` and `AWS_DEFAULT_REGION` hold the region you picked. The agent's SDK signs with the placeholder, and the gateway signs each request again with your key. It re-signs only header-signed SigV4 requests and refuses chunked (streaming) S3 uploads.
- **Connect:** **Open AWS** opens IAM on Create user. Create a user with only the policies the agent needs, create an access key for it (Third-party service), and paste the access key ID, secret access key and region. A temporary key (`ASIA…`) also needs its session token.
- **API fields:** `access_key_id`, `secret_access_key`, `region`, and `session_token` for a temporary key.

| Action | Kind | Strategy | Undo |
|---|---|---|---|
| Delete an S3 object | `aws.s3-delete-object` | compensate | Removes the delete marker. Works only when the bucket has versioning on. |
| Delete several S3 objects (`DeleteObjects`) | `aws.s3-delete-objects` | compensate | Same as above. Held as `approve-only` when the request names versions or uses quiet mode. |
| Delete a specific object version | `aws.s3-delete-version` | approve-only | No |
| Delete a bucket | `aws.s3-delete-bucket` | approve-only | No |
| Any other S3 delete | `aws.s3-delete` | approve-only | No |
| Any other S3 write, including uploads that set ACL headers | `aws.s3-write` | approve-only | No |
| Any IAM write | `aws.iam` | approve-only | No |
| `TerminateInstances` | `aws.ec2-terminate` | approve-only | No |
| Other EC2 or RDS actions starting with `Delete` or `Terminate` | `aws.ec2-delete`, `aws.rds-delete` | approve-only | No |
| `DeleteDBInstance` with a final snapshot | `aws.rds-delete-instance` | compensate | Restores the instance from that snapshot. |
| `DeleteDBInstance` without a final snapshot | `aws.rds-delete-instance` | approve-only | No |
| Any other EC2 or RDS write | `aws.ec2`, `aws.rds` | approve-only | No |

Recorded: `StartInstances`, `StopInstances`, `RebootInstances`, `CreateTags`, `CreateDBSnapshot`. S3 object uploads without ACL headers, read actions (`Describe*`, `Get*`, `List*`, `Simulate*`, `Generate*`) and STS calls go through, as does aborting a multipart upload (`DELETE` with `uploadId`).
