Custom Integrations

Add any HTTP list source — an internal API, a vendor Scape doesn't bundle — as an integration by writing a JSON config. Settings, auth methods, host grammar, pagination, grouping, multi-value fan-out, and row templates, end to end.

An integration is just a JSON config. The bundled connectors (Jira, GitHub, Slack, Notion, Linear, ServiceNow, Bugsnag, Datadog) are the same kind of document you can write yourself. If a tool exposes an HTTP API that returns a list, you can surface it in the Integrations tree — grouped, filtered, openable, agent-readable — without any Scape code.

This page is the config reference. For how connections, profiles, and the library modal work, read the Integrations overview first.

Adding a custom config

Open + Add Integration → Custom… in the integration library. Paste your JSON, and Scape validates it live and tells you exactly what's wrong if it doesn't parse. Save it, then add it to a project like any bundled connector. An agent can also author and save a config for you with the save_integration_view_config MCP tool — but a saved config always waits for your approval before it runs (see the approval boundary).

The shape of a config

A config is one object. These keys are required: schemaVersion, id, title, kind, credential, fetch, item, groupableFields.

{
  "schemaVersion": 1,
  "id": "acme-tickets",
  "title": "Acme Tickets",
  "icon": "activity",
  "kind": "list",
  "credential": {
    "source": "integrationItem",
    "provider": "acme",
    "settings": [
      { "key": "instance", "label": "Instance", "placeholder": "acme", "required": true }
    ],
    "authMethods": [
      { "kind": "token", "label": "API key", "tokenLabel": "API key",
        "auth": { "preset": "bearer", "secret": "{{cred.token}}" } }
    ]
  },
  "fetch": {
    "allowedHosts": ["{{config.instance}}.acme.com"],
    "stages": [
      {
        "name": "tickets",
        "request": {
          "url": "https://{{config.instance}}.acme.com/api/tickets",
          "query": { "q": "{{query}}" }
        },
        "itemsPath": "$.results"
      }
    ]
  },
  "item": {
    "id": "$.id",
    "title": "{{$.title}}",
    "url": "https://{{config.instance}}.acme.com/t/{{$.id}}"
  },
  "groupableFields": [
    { "key": "status", "label": "Status", "path": "$.status" }
  ],
  "query": { "param": "q", "label": "Query", "default": "is:open" },
  "actions": ["copyUrl", "openInSession", "startWorktree"]
}
KeyRequiredWhat it is
schemaVersionAlways 1.
idStable identifier, ^[a-z0-9][a-z0-9-]{1,63}$. Also the credential-sharing key unless credential.provider is set.
titleDisplay name in the library and the tree.
iconA brand tile key or a neutral glyph (see Icons).
kindAlways "list".
credentialWhere auth comes from, and how (see Credentials).
fetchHosts, polling, and the request pipeline (see Fetch).
itemHow each JSON record maps to a row (see Item mapping).
groupableFieldsThe fields you can group and lay out rows by (see Grouping).
queryA user-editable query field with presets (see Query).
defaultGroupByThe grouping the tree opens with, e.g. ["status"].
actionsExtra row actions: copyUrl, copyText, openInSession, startWorktree.
emptyStateHintA one-line hint shown when the tree is empty.

Most objects in the config may carry a $comment string — use it to document why a field is shaped the way it is. It's accepted on the config's structured objects, but not inside free-form string maps (a stage's query or headers, an auth spec's headers, a map transform's table), where a $comment key would become real data — and don't use it inside a request.body, where it isn't stripped as a comment but sent to the API as ordinary payload.

Templates and paths

Two little languages run throughout a config:

  • {{…}} templates interpolate a value into a string. The variables:
    • {{query}} — the current query text.
    • {{config.<key>}} — a settings value the user entered (a domain, an instance name).
    • {{cred.<field>}} — a resolved credential (token, or username/password).
    • {{fan.<name>}} — the current value of a fan-out loop.
    • {{project.repoSlugs}} / {{project.repoQualifiers}} — the current project's repositories, for repo-scoped connectors.
    • {{$.some.path}} — a JSONPath read from the current record.
  • JSONPath ($.…) addresses a field in the fetched JSON. $ is the record root; $.a.b walks objects; $.items[*].name collects every match into a list. Used raw where a config wants a path (item.id, groupableFields[].path, itemsPath) and inside {{…}} where it wants that value spliced into a string.

Credentials

credential.source is one of two:

  • integrationItem — the credential belongs to the connection the user attaches (a per-connection token or username/password). This is the model for anything the user authenticates to directly — Jira, GitHub, ServiceNow.
  • secrets — the credential is one or more named secrets the user stores in the Keychain. credential.secrets maps a template variable to a secret name: { "apiKey": "DD_API_KEY", "appKey": "DD_APP_KEY" } makes {{cred.apiKey}} resolve to the value stored under DD_API_KEY. Datadog (two keys) and Bugsnag (one) use this — a service you authenticate with stored keys rather than an interactive login.

Settings

credential.settings is an array of user-entered fields. Each has a key (referenced as {{config.<key>}}) and a label; optionally default, required, placeholder (example text — not a default), and multi (see fan-out).

"settings": [
  { "key": "instance", "label": "Instance", "placeholder": "acme", "required": true },
  { "key": "site", "label": "Datadog site", "default": "datadoghq.com" }
]

A settings value that sits in a host position (see host grammar) for an integrationItem credential must be required: true with no default — a config can never pre-set the destination a user authenticates to. A secrets-source config may default a host setting when the default is itself a valid public host (Datadog defaults site to datadoghq.com).

Auth methods

For an integrationItem credential, credential.authMethods lets the config offer the user a choice of auth styles. Each method binds a kind to an auth spec, and when the user picks one, that method's spec is used to authenticate. When authMethods is present, a top-level fetch.auth is refused — the methods are the single source of auth.

Two kinds:

  • token — a single secure field (label it with tokenLabel, e.g. "API key"). Reference the value as {{cred.token}}.
  • basicAuth — a username and password pair. Reference as {{cred.username}} and {{cred.password}}. The pair is stored as one blob in the connection's Keychain slot; the password never rides the sync record.

The auth spec itself is either a preset or raw headers:

  • preset: "basic" with user + secret → an HTTP Basic Authorization header.
  • preset: "bearer" with secretAuthorization: Bearer <secret>.
  • preset: "token" with secretAuthorization: token <secret> (GitHub-style).
  • headers: { … } → set arbitrary headers, e.g. { "x-sn-apikey": "{{cred.token}}" }. Use this when the API wants the credential in a custom header, or bare (Linear sends the key as the raw Authorization value, no Bearer).

ServiceNow offers both — username/password or an API key — from one config:

"authMethods": [
  { "kind": "basicAuth", "label": "Username & password",
    "auth": { "preset": "basic", "user": "{{cred.username}}", "secret": "{{cred.password}}" } },
  { "kind": "token", "label": "API key", "tokenLabel": "API key",
    "auth": { "headers": { "x-sn-apikey": "{{cred.token}}" } } }
]

When you don't need a choice, a secrets-sourced or single-style config can skip authMethods and set fetch.auth directly (as GitHub, Slack, Notion, Datadog, and Bugsnag do).

The provider key

credential.provider is the credential-sharing family. Two configs with the same provider can share one global connection — github-prs and github-issues both declare provider: "github", so one GitHub token serves both. Omit it and the config's id is used, meaning no cross-config sharing. Compatibility also requires the same credential kind and that the connection's settings and rendered hosts cover the config's.

Fetch

fetch is the request pipeline.

  • allowedHosts (required) — the allowlist. Every request the integration makes, including pagination follow-ups, must resolve to a host on this list, over HTTPS, on the default port, with no embedded credentials, matching exactly. Templated entries ({{config.instance}}.acme.com) render before matching — never as wildcards. This is the runtime SSRF gate; there is no way around it.
  • pollIntervalSeconds — background poll cadence (minimum 60, default 300).
  • maxItems — cap on items kept per poll (default 1000; a saved config is capped at 2000).
  • auth — a top-level auth spec (same shape as an auth method's auth), used when the config declares no authMethods.
  • stages (required) — one or more request stages, run in order.

Stages

Each stage fetches a list. The common case is a single stage; multiple stages let one feed another.

"stages": [{
  "name": "issues",
  "role": "list",
  "request": {
    "method": "GET",
    "url": "https://{{config.domain}}/rest/api/3/search/jql",
    "query": { "jql": "{{query}}", "fields": "summary,status,priority", "maxResults": "50" }
  },
  "itemsPath": "$.issues",
  "pagination": { "strategy": "cursorToken", "cursorPath": "$.nextPageToken",
                  "cursorParam": "nextPageToken", "isLastPath": "$.isLast" }
}]
  • name — an identifier; a later stage reads a discover stage's output as {{stages.<name>.items}}.
  • role"list" (the default; its items become rows) or "discover" (its items are only fed to a later stage's fan-out — Bugsnag discovers projects, then lists errors per project).
  • requestmethod (GET or POST), url, query (map of string→string, templated), headers, body (any JSON, for POST — Notion and Linear POST a JSON body), timeoutSeconds.
  • itemsPath — JSONPath to the array of records in the response ($ if the body is itself the array, $.result, $.data.viewer.assignedIssues.nodes…).
  • pagination — how to get the next page (see below).
  • fanOut — run this stage once per value in a list (see fan-out).

Pagination

pagination.strategy is one of:

StrategyHow it walksKey fields
noneSingle page
pageNumberIncrement a page numberpageParam, firstPage, pageSize, pageSizeParam
offsetIncrement a row offsetpageParam (offset param), firstPage, pageSize, pageSizeParam
linkHeaderFollow the Link: rel="next" headerpageSize, pageSizeParam
cursorTokenRead a cursor from the body, send it backcursorPath, cursorParam, isLastPath / hasMorePath

For a cursor that must ride in the request body (GraphQL variables.after, Notion's start_cursor), set cursorIn: "body" and cursorBodyPath to where the cursor is injected; page 1 omits the key. Use hasMorePath or isLastPath to know when to stop, and note they read the response boolean differently: hasMorePath continues only while the value is boolean true (absent, false, or non-boolean stops), while isLastPath stops only on boolean true (false or absent keeps going as long as a non-empty cursor remains). Pagination is bounded by a per-poll request budget and maxItems regardless.

Item mapping

item maps each fetched record to a row:

"item": {
  "id": "$.sys_id.value",
  "title": "{{$.short_description.display_value}}",
  "subtitle": "{{$.number.display_value}} · {{$.state.display_value}}",
  "url": "https://{{config.instance}}.service-now.com/{{fan.table}}.do?sys_id={{$.sys_id.value}}",
  "badge": "{{$.priority.display_value}}",
  "updatedAt": "$.sys_updated_on.value",
  "sort": { "path": "$.sys_updated_on.value", "order": "desc" }
}
  • id (required) — a stable identifier for the row (a raw path, or a template combining fields — GitHub PRs use {{fan.repo}}#{{$.number}}). It's what "hide this item" remembers, so keep it stable across polls.
  • title (required) — the row's primary text.
  • url (required) — where the row opens. The link is re-validated at click time against the connection's current settings, and urlAllowedHosts (optional) restricts which hosts a row may open (defends against a hostile payload injecting a link).
  • subtitle, badge, icon — optional secondary text, a trailing pill, and a per-row icon.
  • updatedAt — a path to the record's timestamp; enables the built-in relative-age row source and recency grouping.
  • sortpath + order to sort rows within a group.

Grouping and nesting

groupableFields lists the fields the user can group by (stack several chips to nest levels) and pull into a row template. Each entry:

{ "key": "statusCategory", "label": "Status category",
  "path": "$.fields.status.statusCategory.key",
  "transform": [ { "op": "map", "table": { "new": "To do", "indeterminate": "In progress", "done": "Done" } } ],
  "groupSort": "custom", "order": ["To do", "In progress", "Done"] }
  • key — the field's id (referenced in defaultGroupBy and prefs).
  • label — the chip/column label.
  • path — JSONPath to the value. A [*] path collects multiple values; set multi: true so a record joins every matching group (a PR with three labels appears under all three).
  • nullLabel — the group label for records with no value ("Unassigned", "No label").
  • transform — a pipeline applied to the raw value before grouping (see below).
  • groupSortlabel (default), count, or custom with an explicit order array.
  • displayOnly — when true, the field is a row-template source only: resolved per record but never a grouping pill or group_by axis. Use it for dates, numbers, and free text you want to show on a row but not group by.
  • enrich — fetch an extra per-record detail from another endpoint (GitHub's review decision). Requires url and path (where to read the value from the enrich response), plus an optional integer limit (≥ 1) that bounds how many records are enriched.

Transforms

transform is an ordered list of ops:

OpEffect
mapReplace values via a table (with an optional default, often "{{value}}" to pass through).
dateBucketBucket a timestamp into Today / This week / This month / Older (buckets: "today-week-month-older") — the basis of recency grouping.
dateFormatFormat a timestamp for display: style: "relative" (compact age) or "short". A display op, for displayOnly fields.
tagPrefixFrom a list of key:value tags, keep those with a prefix and strip it (Datadog's team: / env: / service:).
urlLastPathComponentsKeep the last count path components of a URL (turn a repo API URL into owner/name). Requires an integer count (≥ 1).
lowercaseLowercase the value.

The query field

query gives the tree a user-editable query box:

"query": { "param": "sysparm_query", "label": "Query",
  "default": "active=true^ORDERBYDESCsys_updated_on",
  "presets": [
    { "label": "Active, newest first", "value": "active=true^ORDERBYDESCsys_updated_on" },
    { "label": "Assigned to me", "value": "assigned_to=javascript:gs.getUserID()^active=true^ORDERBYDESCsys_updated_on" }
  ] }
  • param — the request query/body parameter {{query}} fills.
  • label, default, presets — the field label, its starting value, and a dropdown of named values.
  • userEditable: false — offer only the presets (GitHub's Open/Closed/All state), no free text.

Host grammar

When a host depends on a user setting, the config templates it — this is what makes a connector multi-account (each connection points at its own instance) while keeping the SSRF gate exact. Three forms:

  • {{config.<key>}} — the whole host is the setting: allowedHosts: ["{{config.domain}}"] (Jira — the user types the full acme.atlassian.net).
  • <labels>.{{config.<key>}} — a fixed prefix, ref last: api.{{config.site}} (Datadog).
  • {{config.<key>}}.<labels> — ref first under a fixed apex: {{config.instance}}.service-now.com (ServiceNow — the user types only the subdomain fragment acme). The literal suffix must be a registrable domain, never a bare TLD.

Only one ref per host, never prefix and suffix. The ref-first form is safe because the user supplies only a fragment under an author-vouched apex, the add form shows the fully rendered host before the credential binds, and the runtime gate exact-matches the final host. For the ref-first case Scape also normalizes what the user pastes: a full acme.service-now.com (or an accidental scheme/path) is stripped back to acme so it can never double-suffix.

Multi-value settings and fan-out

fanOut runs a stage once per value in a list, binding each value to {{fan.<name>}}:

"fanOut": { "over": "{{config.table}}", "as": "table", "concurrency": 1 }

The over source is one of:

  • A multi setting — a setting with multi: true is stored as one comma-separated string the user types (incident, change_request, problem) and expands into a list at fetch time (trimmed, de-duplicated). ServiceNow fans a single connection out across several tables this way. A multi setting may only be used as a fan-out source — never spliced into a string or a host.
  • {{project.repoSlugs}} — the project's repositories (GitHub PRs lists each repo).
  • A discover stage's items{{stages.projects.items}} (Bugsnag lists errors for each discovered project). Reference a field of the current item with {{fan.project.id}}, and group by it via the $._fan.<name> path.

concurrency caps parallel branches. Any fan-out disables conditional (ETag) GETs, and the shared item budget still applies across all branches.

Row templates

By default a leaf row is an icon plus its title (with subtitle and badge shown when they fit). A row template overrides that — an ordered set of components, each a Text, Label, or Icon pulled from a source field, with per-value maps for the icon, the label text, and the color.

  • Sources are the built-in leaf fields (title, subtitle, badge, icon, and updated — the relative age, whenever item.updatedAt resolves) plus every groupableFields entry, including displayOnly ones. This is why the bundled configs add displayOnly fields like number, state, created, branch: to widen what a row can show without adding a request.
  • Per-value maps — for a source like state, map each value to an icon, a label, and a color (a named swatch or a #rrggbb hex). A * entry is the fallback and also covers missing values.
  • Row templates are edited from the palette icon on the tree's grouping row, with a live sample and a per-value table showing exactly what each row will draw. They're stored per profile and sync across your Macs.

An agent can set a row template with set_integration_view_prefs(row_template: …); Scape validates every component source and icon token against the config, and row_template: null clears it back to the default.

Icons

Two different icon vocabularies apply depending on where the icon sits:

  • The top-level icon (the tile shown in the library and the tree header) resolves either a brand tile key for the bundled vendors (jira, github, githubPRs, githubIssues, slack, notion, linear, servicenow, bugsnag, datadog) or one of the neutral glyphs activity, bug, or circleDot. A custom config should pick one of those neutral glyphs rather than wear a brand it isn't — anything else falls back to a generic puzzle-piece.
  • Row-template icon tokens (above) are richer: a Scape semantic icon name (the app's own set) or a ui:<kebab-name> icon from the bundled Untitled UI line library. The ui: prefix is required there; a bare library name is not a token. (These ui: tokens do not apply to the top-level icon.)

Agents and the approval boundary

An agent working in a session can go a long way with integrations — but never past a hard line that keeps you in control of credentials and global state.

An agent can:

  • Author, validate_integration_view_config, and save_integration_view_config — a saved config is stored unapproved.
  • add_integration_profile referencing an existing connection (bundled or an approved config).
  • add_integration_view_item for a bundled connector — which creates an unconnected profile you finish by choosing Connect on this device.
  • set_integration_view_prefs to change grouping, query, hidden items, or a row template.
  • open_add_integration / open_integration_settings to open the library or a connection's settings for you.

An agent can never:

  • Enter or read a credential value (list_integration_connections returns secret names at most).
  • Approve a config that uses your stored secrets — approval is stamped only by your in-app Approve & Add, on the device, and any re-save of a config revokes prior approval.
  • Create, edit, rename, or delete a global connection — connections are global synced state, so every mutation is your action in the UI.

The result: an agent can prepare and stage an integration for you, but the moment a real credential, a stored-secret config, or a shared connection is involved, it stops and hands the decision back. This is the same boundary described in the Integrations overview.