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"]
}| Key | Required | What it is |
|---|---|---|
schemaVersion | ✓ | Always 1. |
id | ✓ | Stable identifier, ^[a-z0-9][a-z0-9-]{1,63}$. Also the credential-sharing key unless credential.provider is set. |
title | ✓ | Display name in the library and the tree. |
icon | A brand tile key or a neutral glyph (see Icons). | |
kind | ✓ | Always "list". |
credential | ✓ | Where auth comes from, and how (see Credentials). |
fetch | ✓ | Hosts, polling, and the request pipeline (see Fetch). |
item | ✓ | How each JSON record maps to a row (see Item mapping). |
groupableFields | ✓ | The fields you can group and lay out rows by (see Grouping). |
query | A user-editable query field with presets (see Query). | |
defaultGroupBy | The grouping the tree opens with, e.g. ["status"]. | |
actions | Extra row actions: copyUrl, copyText, openInSession, startWorktree. | |
emptyStateHint | A 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, orusername/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.bwalks objects;$.items[*].namecollects 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.secretsmaps a template variable to a secret name:{ "apiKey": "DD_API_KEY", "appKey": "DD_APP_KEY" }makes{{cred.apiKey}}resolve to the value stored underDD_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 withtokenLabel, 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"withuser+secret→ an HTTP BasicAuthorizationheader.preset: "bearer"withsecret→Authorization: Bearer <secret>.preset: "token"withsecret→Authorization: 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 rawAuthorizationvalue, noBearer).
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'sauth), used when the config declares noauthMethods.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 adiscoverstage'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).request—method(GETorPOST),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:
| Strategy | How it walks | Key fields |
|---|---|---|
none | Single page | — |
pageNumber | Increment a page number | pageParam, firstPage, pageSize, pageSizeParam |
offset | Increment a row offset | pageParam (offset param), firstPage, pageSize, pageSizeParam |
linkHeader | Follow the Link: rel="next" header | pageSize, pageSizeParam |
cursorToken | Read a cursor from the body, send it back | cursorPath, 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, andurlAllowedHosts(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.sort—path+orderto 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 indefaultGroupByand prefs).label— the chip/column label.path— JSONPath to the value. A[*]path collects multiple values; setmulti: trueso 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).groupSort—label(default),count, orcustomwith an explicitorderarray.displayOnly— whentrue, the field is a row-template source only: resolved per record but never a grouping pill orgroup_byaxis. 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). Requiresurlandpath(where to read the value from the enrich response), plus an optional integerlimit(≥ 1) that bounds how many records are enriched.
Transforms
transform is an ordered list of ops:
| Op | Effect |
|---|---|
map | Replace values via a table (with an optional default, often "{{value}}" to pass through). |
dateBucket | Bucket a timestamp into Today / This week / This month / Older (buckets: "today-week-month-older") — the basis of recency grouping. |
dateFormat | Format a timestamp for display: style: "relative" (compact age) or "short". A display op, for displayOnly fields. |
tagPrefix | From a list of key:value tags, keep those with a prefix and strip it (Datadog's team: / env: / service:). |
urlLastPathComponents | Keep the last count path components of a URL (turn a repo API URL into owner/name). Requires an integer count (≥ 1). |
lowercase | Lowercase 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 fullacme.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 fragmentacme). 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
multisetting — a setting withmulti: trueis 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. Amultisetting 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, andupdated— the relative age, wheneveritem.updatedAtresolves) plus everygroupableFieldsentry, includingdisplayOnlyones. This is why the bundled configs adddisplayOnlyfields likenumber,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#rrggbbhex). 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 glyphsactivity,bug, orcircleDot. 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. Theui:prefix is required there; a bare library name is not a token. (Theseui:tokens do not apply to the top-levelicon.)
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, andsave_integration_view_config— a saved config is stored unapproved. add_integration_profilereferencing an existing connection (bundled or an approved config).add_integration_view_itemfor a bundled connector — which creates an unconnected profile you finish by choosing Connect on this device.set_integration_view_prefsto change grouping, query, hidden items, or a row template.open_add_integration/open_integration_settingsto open the library or a connection's settings for you.
An agent can never:
- Enter or read a credential value (
list_integration_connectionsreturns 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.