spotlight_integration
The spotlight_integration resource, and the integration resource of the
API: a team's definition of an outside service, with the webhooks it sends,
the happenings they raise, the tools agents may call on it, and how Spotlight
connects to it. Its body is a definition object, sent as the
definition of spotlight_integration in Terraform or as the JSON body of the
resource API.
| Kind | Endpoint | Terraform | Address |
|---|---|---|---|
integration | /api/resources/teams/{teamId}/integrations | spotlight_integration | <team key>/<integration> |
See Resource API for the operations, headers and errors every kind shares, and Expressions for how the expressions inside a definition are written and what each sees.
Example
resource "spotlight_integration" "core_acme" {
team_id = spotlight_team.core.id
definition = {
integration = "acme"
name = "Acme"
webhook = {
verify = {
hmac = { secret = "secret.ACME_SIGNING", header = "acme-signature" }
}
events = [{
when = "$${{ body.type == \"issue\" && body.action == \"updated\" }}"
raise = {
happening = "acme.issue.updated"
marks = [{ kind = "acme.issue", value = "$${{ body.issue.id }}" }]
}
}]
}
actions = {
comment = {
request = {
method = "POST"
url = "https://api.acme.example/comments"
body = { issue = "$${{ inputs.issue }}", text = "$${{ inputs.body }}" }
auth = { secrets = ["secret.ACME_API_KEY"] }
}
}
}
tools = {
comment = {
does = "comment"
description = "Comment on an Acme issue."
inputs = {
issue = { type = "string", required = true }
body = { type = "string", required = true }
}
}
}
}
}Acme stands for any HTTP service. A station gives this tool as acme.comment.
Top level
| Field | Type | Default | Description |
|---|---|---|---|
integration | string | required | The name the host is addressed by. Prefixes its tools, happenings and webhook address. Fixed: an update that changes it is refused; Terraform replaces the resource. |
name | string | none | The name shown in the web app. Without it, the integration value. |
types | map of name to object | {} | Named shapes that expressions are type-checked against. |
connection | object | none | How the team links to the host. See connection. |
webhook | object | none | Deliveries the host sends. See webhook. |
poll | object | none | A request Spotlight repeats. See poll. |
schedule | object | none | An action Spotlight runs on a cron schedule. See schedule. |
actions | map of name to object | {} | Requests to the host. See actions. |
tools | map of name to object | {} | Actions offered to agents and hooks. See tools. |
openapi | object | none | An OpenAPI description to generate tools from. See API descriptions. |
graphql | object | none | A GraphQL API to generate tools from. See API descriptions. |
mcp | object | none | A remote MCP server whose tools Spotlight lists and calls. See MCP servers. |
on | map of name to object | {} | Actions run when a task event happens. See on. |
A definition must do something: it has a webhook, a poll, a schedule, at
least one action, at least one on binding, an openapi, a graphql or an
mcp. Otherwise it is refused with
must do something: a webhook, a poll, a schedule, an action to run, or an API description or MCP server to offer tools from.
A definition with two of openapi, graphql and mcp is refused with
offers tools from one source: an openapi, a graphql or an mcp, not two.
A read adds three fields that cannot be set:
| Field | Type | Description |
|---|---|---|
source | string | Equal to integration. |
tools | map of name to string | Each tool the integration offers, written or generated, by its name in the integration, to the name stations and hooks use, as in { "comment": "acme.comment" }. |
described | object or null | The API description or MCP server as Spotlight last read it. null when the definition names none. See Described. |
In Terraform, tools is known only after apply when the definition has an
openapi, a graphql or an mcp. A station that names a generated tool through it that
is not there fails the apply, not the plan.
Names
| Name | Pattern | Example |
|---|---|---|
integration | lowercase letters and digits, one word; not catalog | acme |
| action, tool | lowercase words joined by dashes | post-message |
| tool input | a letter or _, then letters, digits, _ and -, at most 64 characters; not json | issue, team_id |
| happening | the integration's own name, a dot, then dotted words of lowercase letters, digits, _ and - | acme.issue.updated |
| mark kind | a source name, a dot, then dotted words as for a happening | acme.issue |
| secret reference | secret. and a secret name of capitals, digits and _, starting with a capital, at most 64 characters | secret.ACME_API_KEY |
| type name | starts with a capital, then letters and digits | Issue |
A happening that does not begin with the integration's name is refused. One integration may not raise the same happening twice, offer the same tool twice, or bind the same task event twice.
An integration named github receives no webhook deliveries at its team
address: every delivery there returns 401. The name github belongs to
the built-in GitHub source.
Shapes
A shape is a map from field name to a type, written as text such as string,
list<Issue> or a type name from types, or to a nested shape. Shapes appear
in types, webhook.shape, poll.shape and a request's response. They are
used only to type-check expressions. A shape that cannot be read, or types
that name themselves in a cycle, produce a warning and are then ignored.
Connection
| Field | Type | Default | Description |
|---|---|---|---|
holder | string | team | team or requester. Whose connection the integration's tools act with. See Holder. |
oauth2.discover | boolean | false | Find the endpoints, the registration endpoint and the scopes from what the MCP server named in mcp.url publishes. See Discovery. |
oauth2.authorize | string | required unless discover | The host's authorization endpoint. |
oauth2.token | string | required unless discover | The host's token endpoint. |
oauth2.scopes | array of strings | [] | Scopes asked for, joined by spaces. |
oauth2.identity | string | none | An expression. Names the linked account, shown as "Connected as" in the web app. Sees response, the token endpoint's response. |
oauth2.registration | string | none | A URL. The host's dynamic client registration endpoint (RFC 7591). Used only when the deployment holds no OAuth client for the integration. See Client registration. |
oauth2.resource | string | none | A URL. Sent as the resource parameter (RFC 8707) on the authorization URL and on every token request. |
manual.secrets | array of strings | [] | Secrets the connection uses, each a secret reference, secret.<NAME>. |
Without discover, oauth2 must name both authorize and token. With
discover = true it takes only scopes, identity and resource; naming
authorize, token or registration beside it is refused as an unrecognized
key. discover without an mcp block is refused at connection.oauth2 with
discovers how to connect from its MCP server, but mcp.url names no MCP server.
holder = "requester" without oauth2 is refused with
Integration <integration> takes each person's connection. A person can connect only over oauth2.
connection = {
holder = "requester"
oauth2 = {
authorize = "https://acme.example/oauth/authorize"
token = "https://acme.example/oauth/token"
registration = "https://acme.example/oauth/register"
resource = "https://api.acme.example"
scopes = ["issues:write"]
}
}Holder
| Holder | A tool acts with | Polls, schedules and on bindings act with |
|---|---|---|
team | The team's connection. | The team's connection. |
requester | The connection of the person who started the task: the task's Raised by. | The team's connection. |
A team connection is made by an owner or admin of the workspace. A person's
connection is made by that person, who must be on the team, and only for an
integration whose holder is requester. See
Integration connections.
When a tool of a requester integration is called, nothing is sent until
Spotlight has that person's connection. It never uses the team's connection in
its place.
| Situation | Result |
|---|---|
| The person has connected. | The call is made with their access token. |
| The person has not connected, or the host refused to renew their connection. | The task is assigned to them and put to them with a request to connect. The call fails with <tool> acts as the person who started this task, and they haven't connected <integration>. They've been asked in their inbox. Nothing was sent. Stop here and end your turn: you're resumed once they connect. When they connect, the request is answered with Connected <integration>. |
| No person started the task. | The call fails with <tool> was not used, so nothing was sent: nobody started this task, so there is nobody whose <integration> connection it could act with. |
| The person who started the task is no longer on the team. | The call fails with <tool> was not used, so nothing was sent: the person who started this task is no longer on the team, so it cannot act with their <integration> connection. |
| The task already waits on a person for something else. | Nobody is asked. The call fails with <tool> was not used, so nothing was sent: this task is already waiting on someone, so nobody can be asked to connect <integration> until they answer. |
<integration> is the integration's name. A hook
step that calls a tool of a requester integration fails with not-allowed.
OAuth connect and callback
An integration with connection.oauth2 shows a Connect team account
button, and Change account once linked, on the team's Integrations settings
page. An integration whose holder is requester also shows
Connect your account there, where each person connects their own account. It
links only when Spotlight has an OAuth client for it: one the deployment holds
for the integration's name in OAUTH_CLIENTS, or one registered at oauth2.registration.
| Step | Endpoint | Behaviour |
|---|---|---|
| Begin | POST /api/teams/{teamId}/integrations/{source}/connect | An optional body with for and back. for is team, the default, which needs a signed-in owner or admin of the workspace (the administer permission); me, the caller's own connection, needs a signed-in person on the team, and an integration whose holder is requester. back is where to land afterwards, a path under /w/<slug>/inbox or /w/<slug>/settings. Returns { "url": ... }, the authorization URL with client_id, redirect_uri, response_type=code, scope, a signed state, code_challenge with code_challenge_method=S256, and resource when set, and sets a one-time cookie. 400 when for is neither. 404 when there is no such team, the person may not make that connection, the host does not link over OAuth, or there is no client. |
| Callback | GET /api/integrations/oauth/callback | Needs the same signed-in person, still allowed to make the connection, and the cookie. Exchanges the code with its PKCE verifier and keeps the grant sealed. Redirects to back with ?connected=<source>, or else to /w/<slug>/settings/team/<teamId>/integrations?connected=<source>, or to /?integration=failed. |
| List | GET /api/teams/{teamId}/connections | Needs a signed-in person on the team. Returns mine, the caller's own connections, each with source, handle, connectedAt and tools, the caller's rule for each tool as { tool, rule }, and people, every person's connection in the team with personId and name as well, for an owner or admin, or null for anyone else. Never a token. |
| Disconnect | DELETE /api/teams/{teamId}/integrations/{source}/connections/mine | Needs a signed-in person on the team. Removes the caller's own connection. Returns 204. There is no endpoint to remove another person's connection. |
| Set a tool rule | PUT /api/teams/{teamId}/integrations/{source}/connections/mine/tools/{tool} | Needs a signed-in person on the team. A body { "rule": ... }: allowed, the default, follows the team's rule; needs-approval asks the caller first; blocked never runs the tool. {tool} is the tool's full name, as in linear.comment. Applies only to calls made with the caller's own connection, and the stricter of the team's rule and the caller's wins. See Person rules. Returns 204. 404 when the caller is not on the team, the integration does not offer the tool, or its connection.holder is not requester. 422 validation error for a malformed tool name or any other rule. |
The state expires after 10 minutes. A request with auth.oauth2 sends the
access token as Authorization: Bearer.
Renewal
| When | What Spotlight does |
|---|---|
| An access token has less than 5 minutes left. | Renews it with the refresh token. |
A request with auth.oauth2 is answered 401. | Renews the token once and sends the request once more with the new one. Any other status, or a request authenticated by secrets, is not retried. |
A description fetch with auth.oauth2 is answered 401. | Renews the team's token once and fetches the description once more with the new one. |
One renewal of a connection runs at a time, across every control-plane
instance, under a claim held for 30 seconds. A request answered 401 while
another renewal holds the claim waits for it, for up to 30 seconds, and is sent
again with the new token. A refresh token the host rotates is kept. When the
host refuses to renew, the connection is marked refused until it is made again.
A person whose connection is refused is asked to connect again the next time a
tool needs it.
Client registration
When the deployment holds no OAuth client for the integration and
oauth2.registration is set, Spotlight registers a client at that endpoint
the first time someone in the team connects, once per team and integration.
The registration is a POST of JSON with client_name Spotlight, the
callback URL as the one redirect_uris entry, grant_types
authorization_code and refresh_token, response_types code and
token_endpoint_auth_method client_secret_post. The answer must hold a
client_id; a client_secret is kept sealed, with its
client_secret_expires_at. The client is reused until the registration
endpoint or the callback URL changes or the client secret expires, and is then
registered again. A client without a secret sends no client_secret on token
requests.
The registration endpoint must be on the deployment's outbound allow-list, as the token endpoint must.
Discovery
With oauth2.discover, Spotlight finds where to connect from the MCP server
named in mcp.url, following the MCP authorization specification, when a
connection begins, when its code is exchanged and when it is renewed:
- It sends the server an
initializerequest without credentials. A401whoseWWW-Authenticatenamesresource_metadatapoints at the server's protected resource metadata (RFC 9728). Otherwise Spotlight reads/.well-known/oauth-protected-resourcewith the server's path after it, and then at the root. - It reads the authorization server metadata (RFC 8414, then OpenID Connect
discovery) of the first of the metadata's
authorization_servers, or of the server's own origin when there is none.
| Connection field | Comes from |
|---|---|
authorize | authorization_endpoint |
token | token_endpoint |
registration | registration_endpoint, when the authorization server has one. See Client registration. |
scopes | oauth2.scopes when set, else the protected resource metadata's scopes_supported. |
resource | oauth2.resource when set, else the protected resource metadata's resource, else mcp.url. |
Discovery fails when the authorization server metadata's issuer is not the
URL it was read for (RFC 8414), when its code_challenge_methods_supported
does not list S256, or when the protected resource metadata names a
resource that is neither mcp.url nor on its origin above its path. Then the
integration does not link, and beginning a connection answers 404. Every
request goes only to hosts on the deployment's outbound allow-list, and each
waits at most 15 seconds. What was found is kept for 10 minutes on each control
plane server.
The client a connection uses is bound to the authorization server it was made with: its issuer, authorization endpoint and token endpoint.
| Client | Bound by |
|---|---|
Registered at registration_endpoint | Spotlight keeps the three with the registration. When a later discovery finds others, the next connection registers again, and exchanging a code or renewing a token with the old client is refused. |
| Held by the deployment for the integration | Its configured issuer. A deployment client without an issuer, or with another one, is not used for a discovered server, and the integration does not link. |
Webhook
| Field | Type | Default | Description |
|---|---|---|---|
shape | object | none | The shape of body, for type-checking. |
verify | object | required | How a delivery is verified. Exactly one of hmac, token, asymmetric or jwt. See verify. |
respond | object | none | A handshake response. when (an expression that returns bool) and body (an expression). When when holds, Spotlight returns 200 with body and does not read the delivery further. |
mine | string | none | An expression that returns bool. When it holds, the delivery is the host echoing Spotlight's own post, and its happenings are dropped. |
route | object | none | by (expression). Type-checked. Deliveries go to the integration's team whatever it returns. |
parse | string | json | json or form. Kept in the definition. Not used when a delivery is read. |
delivery | string | none | An expression. The delivery's id. A delivery with an id already taken is not processed again. Without it, the SHA-256 of the raw body. |
events | array of objects | required | At least one. See events. |
Delivery address
On a self-hosted Spotlight, use your deployment's address in place of app.usespotlight.ai.
POST https://app.usespotlight.ai/api/webhooks/w/<workspace slug>/t/<team key>/<integration>| Status | When |
|---|---|
200 with the respond.body text | The delivery is verified and respond.when holds. |
204 | The delivery is verified and accepted. It is read afterwards. |
401 | The signature does not verify, the signing secret is missing or cannot be read, the delivery's freshness.at cannot be read or is further from now than freshness.within, the integration has no webhook, the delivery has no raw body, or there is no such workspace, team or integration. The response does not say which. |
An accepted delivery whose processing fails is retried up to 8 attempts in all, from 2 seconds apart doubling to 1 hour.
Verify
hmac:
| Field | Type | Default | Description |
|---|---|---|---|
algorithm | string | sha256 | sha256, sha1 or sha512. The HMAC digest. |
secret | string | required | The signing secret. A secret reference, secret.<NAME>. |
signed | string | none | An expression. The text that was signed. Sees body, headers and raw. Without it, the raw body. |
encoding | string | hex | hex or base64. How the signature is written. |
prefix | string | "" | Text written before the signature, as in v0=. |
header | string | none | The header that carries the signature. A delivery without it is refused, and without header no delivery verifies. |
freshness.at | string | none | An expression. The delivery's time in Unix seconds. A delivery whose time cannot be read is refused. Required with freshness. |
freshness.within | string | none | Seconds, written as 300s. A delivery further than this from now is refused. Required with freshness. |
token:
| Field | Type | Default | Description |
|---|---|---|---|
secret | string | required | The shared token. A secret reference, secret.<NAME>. |
header | string | none | The header that carries it. Compared whole. A delivery without it is refused, and without header no delivery verifies. |
asymmetric:
| Field | Type | Default | Description |
|---|---|---|---|
algorithm | string | required | ed25519 or rsa-sha256. The signature algorithm. |
key | string | required | The public key: hex of the raw key for ed25519, PEM for rsa-sha256. May hold expressions. |
signed | string | none | An expression. The text that was signed. Without it, the raw body. |
encoding | string | hex | hex or base64. How the signature is written. |
header | string | none | The header that carries the signature. A delivery without it is refused, and without header no delivery verifies. |
freshness | object | none | As for hmac. |
jwt:
| Field | Type | Default | Description |
|---|---|---|---|
jwks | string | required | Where the signing keys are published. |
header | string | authorization | The header that carries the token. A Bearer prefix is removed. |
audience | string | none | When set, the token's aud must hold it. |
issuer | string | none | When set, the token's iss must equal it. |
A jwt token must be signed with RS256. An expired token is refused.
verify accepts no other way. oauth2 and secrets are refused with
verifies a delivery by hmac, token, asymmetric or jwt.
Events
webhook.events and poll.events are lists of events. Each event is read
against every delivery or poll response, in order, and raises one happening when
its when holds.
| Field | Type | Default | Description |
|---|---|---|---|
forEach | string | none | An expression that returns a list. The event is read once for each item, which expressions see as item. |
when | string | required | An expression that returns bool. The event raises only when it holds. An expression that fails to evaluate counts as false. |
lookup.request | object | none | A further request made before raising. See requests. When it does not succeed, the event raises without the captures. |
lookup.capture | map of name to string | none | Values read from the lookup's response. Raise expressions see each by its name. Each value is an expression. Required with lookup. |
raise | object | required | See raise. |
Raise
| Field | Type | Default | Description |
|---|---|---|---|
happening | string | required | The happening's name. Begins with the integration's name. |
label | string | none | What a person picking a happening reads. Without it, the happening's name. |
told | string | none | How the happening reads in a note on the task. Without it, the happening's name. |
spoken | boolean | false | Whether the happening carries words to the task. |
say | string | none | An expression. The words carried. |
opens | object | none | Opens a task when no task is found. See below. |
branch | string | none | An expression. A branch name that finds a task. |
marks | array of objects | [] | Marks that find a task, or are kept on the task this opens. |
attaches | object | none | Files attached to the task. See below. |
A raise must name a branch or at least one mark with find: true; otherwise
it is refused with needs a branch or a mark. Without one, no task can be found from it.
When neither evaluates to a value, reading the delivery fails.
Mark:
| Field | Type | Default | Description |
|---|---|---|---|
kind | string | required | The mark kind, source first. |
value | string | required | An expression. The mark's value. A mark whose value is empty is not kept. |
find | boolean | true | true: the mark finds the task the happening is about. false: the task this opens keeps it, and it never finds a task. |
opens:
| Field | Type | Default | Description |
|---|---|---|---|
onto | string | required | The role of the line to open on, in lowercase words joined by dashes. |
at | string | none | The key of the station to open at. Without it, or with a key the line does not have, the line's first work station. |
title | string | required | An expression. The task's title. No task opens when it is empty. |
description | string | none | An expression. The task's description. |
assignBy.email | string | none | An expression. The email of the person to assign. |
A task opens only on a line of the team whose role is onto, and only when
the raise carries at least one mark.
attaches:
| Field | Type | Default | Description |
|---|---|---|---|
each | string | required | An expression that returns a list, one entry per file. Later fields see each entry as file. |
name | string | required | An expression. The file name. An entry with no name is skipped. |
type | string | none | An expression. The content type. |
size | string | none | An expression. The size in bytes. |
fetch | object | required | The request that downloads the file. |
Poll
| Field | Type | Default | Description |
|---|---|---|---|
shape | object | none | The shape of body, for type-checking. |
every | string | required | Whole seconds between polls, written as 60s. |
request | object | required | The request made each time. Sees cursor and connection. |
cursor | string | none | An expression. The next cursor, read from the response. Sees body and cursor. |
events | array of objects | required | At least one. Read against the response, which expressions see as body. |
A poll whose request is refused raises nothing.
Schedule
| Field | Type | Default | Description |
|---|---|---|---|
cron | string | required | A cron expression: five fields, or six with seconds first. |
do | string | required | The action to run. It must be defined in actions. |
Actions
Each entry of actions is { "request": <request> }. Action names are
lowercase words joined by dashes, at most 64 characters.
Requests
A request appears in actions, poll.request, lookup.request and
attaches.fetch.
| Field | Type | Default | Description |
|---|---|---|---|
response | object | none | The shape of response, for type-checking. |
method | string | POST | GET, POST, PUT, PATCH or DELETE. The HTTP method. |
url | string | required | The address. May hold expressions. |
query | map of name to string | {} | Query parameters. A parameter that evaluates to nothing is left out. Values may hold expressions. |
headers | map of name to string | {} | Request headers. A header that evaluates to nothing is left out. Values may hold expressions. |
body | string, array or object | none | A string is sent as it is. A list or object is sent as JSON, with content-type: application/json unless a header sets it. Strings inside may hold expressions. |
auth | object | none | Exactly one of oauth2 or secrets. See below. |
expect.success | string | none | An expression that returns bool. When set, decides whether the request succeeded. |
expect.status | array of integers | none | Statuses that count as success, when expect.success is not set. |
capture | map of name to string | none | Values read from the response. Each value is an expression. |
retry | object | none | times (integer, default 0) and backoff (string). Kept in the definition; a request is sent once. |
Without expect, a request succeeds on any 2xx status. An expression in
url, query, headers or body that fails to evaluate fails the request
before it is sent.
auth:
| Field | Type | Default | Description |
|---|---|---|---|
oauth2.connection | boolean | true | Must be true. Sends the connection's OAuth access token as Authorization: Bearer: the team's, or for a tool of a requester integration, that of the person who started the task. See Holder. |
secrets | array of strings | none | Secret references, secret.<NAME>, at least one when set. Each secret is put in its bound header or query parameter on requests to the hosts it is bound to. A request to any other host goes without it, and so does a redirect to another host. |
auth accepts no other way, and is refused with authenticates a request by its oauth2 connection, or by the secrets it wears toward the hosts they are bound to. See Bindings.
Every request goes only to hosts on the deployment's outbound allow-list,
OUTBOUND_ALLOWED_HOSTS. A
request to another host is refused with Outbound call to <host> refused. Host is not on the allowed list.
A request is also refused with Outbound call to <url> refused. URL is not valid.,
Outbound call to <host> refused. redirected more than 5 times, or
Outbound call to <host> refused. is where an https request was redirected to over plain http, which Spotlight does not follow.
Tools
| Field | Type | Default | Description |
|---|---|---|---|
does | string | required | The action the tool runs. It must be defined in actions. |
description | string | required | When an agent should use the tool. May not be empty. |
approval.because | string | none | When set, each use first asks a person, who is shown this text. May not be empty. The tool may not then have an input named intent. |
inputs | map of name to object | {} | The tool's inputs. |
output | string | none | An expression. What the tool returns. Sees response, status, capture and inputs. Without it, the capture map. |
Input:
| Field | Type | Default | Description |
|---|---|---|---|
type | string | string | string, number, integer or boolean. The input's type. Expressions see boolean as bool, and number and integer as dyn. |
schema | object | none | A JSON Schema for the input, in place of type, for a list, an object or a constrained value. An input with both type and schema is refused with takes a type, or a JSON Schema as its schema, but not both. Expressions see a string schema as string, a boolean one as bool, and any other as dyn. See Input schemas. |
description | string | none | Shown to the agent. |
required | boolean | false | Whether the agent must give it. |
default | string | none | The value when none is given. Sees task. May hold expressions. |
piped | boolean | false | The input may be piped to the tool command. At most one input per tool, and it must take text. |
An input may not be named json: Spotlight keeps that name for handing a tool
every input at once, and refuses the tool with
tool <integration>.<tool> cannot take reserved input json. That input hands a tool every input at once.
An agent calls a tool as <integration>.<tool>. A tool with approval also
takes intent from the agent, which is shown to the person and never sent;
see What the agent wants. A tool whose request fails
returns the integration answered <status>: <body>, with the body cut at 500
characters. See Approvals
and Require approval before a tool runs.
For an integration with connection.oauth2, the answer to a tool call carries
actingAs beside output: whose connection the call used, as holder
(team or person), personId (for a person) and handle, the account the
host named. spotlight <tool> prints only the output. See Holder.
A tool that a station gives or a hook matches or calls cannot be withdrawn
from the definition; the update is refused with 409.
Input schemas
Before a call reaches the host, Spotlight checks each input against its schema.
These keywords are kept: type (one or a list of string, number,
integer, boolean, object, array, null), enum, const,
properties, required, additionalProperties (as a boolean), items,
anyOf, oneOf, allOf (merged into one object), description, default,
examples, minimum, maximum, minLength, maxLength, pattern and
format. Others are dropped. pattern and format are kept for the agent to
read and not checked. A schema is read 8 levels deep.
A value given as text, as a command-line flag gives it, is read as the
input's type first: a number from its decimal digits (not 0x10), true or
false, and a list or object from its JSON. A call whose input does not fit is refused before
anything is sent, naming where:
| Message | Cause |
|---|---|
<tool> needs <input> | A required input was not given. |
<tool> takes <input>, and <where> <why> | An input does not fit its schema, as in linear.issue-create takes input, and input.teamId is not text. |
<why> is one of is not <type>, is not one of <values>, is not <value>,
needs <field>, takes no <field>, is shorter than <n> characters,
is longer than <n> characters, does not match <pattern>,
is less than <n>, is more than <n>, is none of the shapes it may take,
and is not JSON for a value that is not JSON or nests more than 64 levels.
A person who changes a call's inputs while approving it is checked against the same schemas. See Changed inputs.
API descriptions
openapi and graphql name an API description: a document that lists an
API's operations and what each takes. Spotlight reads it and offers one tool
for each operation, beside the tools in tools. A definition has at most one
of the two. See
Generate tools from an API description.
openapi = {
url = "https://petstore3.swagger.io/api/v3/openapi.json"
baseUrl = "https://petstore3.swagger.io/api/v3"
revision = "1"
auth = { secrets = ["secret.PETSTORE_KEY"] }
tools = {
include = ["*pet*"]
exclude = ["delete-*"]
approval = { "update-pet" = false }
}
}openapi
| Field | Type | Default | Description |
|---|---|---|---|
url | string | none | Where the description is read from, as JSON or YAML. Exactly one of url or document. |
document | object | none | The description written out in full. |
baseUrl | string | none | Where every generated call is sent. Without it, each operation's first server, else its path's, else the description's, with variables at their defaults and read relative to url; with no server at all, the root of url. |
revision | string | none | Any text. A change makes Spotlight read the description again. |
auth | object | none | As a request's auth. Sent with every generated call, and with the fetch of the description when it is on the host of baseUrl. |
tools | object | {} | Which tools are generated, and which ask first. See Choosing tools. |
Spotlight reads OpenAPI 3.0 and 3.1. An openapi with both or neither of
url and document is refused with
is read from a url, or written out in full as its document, but not both.
graphql
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The endpoint. Generated calls are sent to it as POST. Without document, Spotlight reads the schema by asking the endpoint for it (introspection). |
document | string | none | The schema in GraphQL's schema language, read in place of asking the endpoint. |
depth | integer | 2 | 1 to 5. How many levels of fields a generated call selects in its answer. |
revision | string | none | Any text. A change makes Spotlight read the schema again. |
auth | object | none | As a request's auth. Sent when Spotlight asks for the schema, and with every generated call. |
tools | object | {} | See Choosing tools. |
A generated call selects, at each level, every field that takes no required
argument: scalar and enum fields at every level, and fields with fields of
their own while depth allows. Input objects in arguments are followed 3
levels deep; deeper ones read as any object.
Choosing tools
| Field | Type | Default | Description |
|---|---|---|---|
include | array of strings | [] | Patterns. When set, only operations whose tool name matches one are offered. |
exclude | array of strings | [] | Patterns. Operations whose tool name matches one are not offered. |
approval | map of tool name to object or false | {} | An object with because makes that tool ask a person first, who is shown the text. false: the tool never asks. |
A pattern is a tool name in lowercase letters, digits and -, where *
matches any run of characters. Anything else is refused with
is a tool name in lowercase words joined by dashes, with * for any run of letters, as in *-issue*.
A tool in tools with the same name as a generated one is offered in its
place. A key of approval that names no generated tool gives a warning:
names <key>, but no operation of its <OpenAPI description, GraphQL schema or MCP server> has that name.
At most 1,000 tools are generated, counted after include, exclude and the
tools in tools. More is refused at <openapi, graphql or mcp>.tools with
offers <n> tools from its <OpenAPI description, GraphQL schema or MCP server>. A station can choose between at most 1000 tools. Please name the tools to offer in <openapi, graphql or mcp>.tools.include.
Generated tools
Each OpenAPI operation, and each GraphQL query and mutation field, becomes a tool:
| Part | From |
|---|---|
| Name | The operationId, or the GraphQL field's name, in lowercase words joined by dashes: findPetsByStatus is find-pets-by-status, and issueCreate is issue-create. An operation without an operationId is named from its method and path: GET /store/inventory is get-store-inventory. |
| Description | The operation's summary, else its description, else its method and path; for GraphQL, the field's description, else query <field> or mutation <field>. At most 1,000 characters. |
| Inputs | The operation's path, query and header parameters, and the GraphQL field's arguments, each by its own name, as in petId or team_id, with its JSON Schema. A JSON request body is the input body. |
| Approval | OpenAPI POST, PUT, PATCH and DELETE, and GraphQL mutations, ask a person first, with the reason It changes data in <name>., where <name> is the definition's name, else integration. tools.approval overrides it. |
| Output | For OpenAPI, the response's JSON. For GraphQL, data.<field> of the answer; an answer with errors fails the call. An answer over 4 MB fails the call with it is larger than the 4 MB Spotlight reads from a tool. |
OpenAPI schemas are followed 8 levels deep, and each operation's input schemas
are cut short after 4,000 schema nodes; deeper parts read as any value. Only
$refs inside the description are followed; any other reads as any value.
Spotlight sends the accept, content-type and authorization headers
itself, so header parameters by those names are not inputs. An optional
parameter or body a tool cannot send is left out.
Every request, written or generated, follows at most 5 redirects. Each goes only to a host on the
deployment's outbound allow-list and never from https to http, and
credentials are not sent to a host other than the request's own.
An operation that gets no tool gives a warning at openapi or graphql, as
passes over <operation>. <reason>:
| Reason | Cause |
|---|---|
Another operation is also called <name> | An earlier operation has the same tool name. The first is kept. |
It has no name that a tool can take | Its operationId, method and path, or field name give no tool name. |
It needs its <in> parameter <name>, which a tool cannot send | A required cookie parameter, a required accept, content-type or authorization header, or a required parameter whose name is not letters, digits, _ and -, such as $filter. |
One of its parameters cannot be read | A parameter is not an object with name and in, or its $ref points nowhere. |
It takes <name> twice | Two parameters in different places share a name. |
It takes a parameter called body as well as a body | A parameter named body beside a JSON request body. |
It sends a body that is not JSON. A tool cannot send such a body yet | A required request body with no JSON media type. |
It takes <name>, which a tool cannot take | A GraphQL argument whose name a tool input cannot have. |
It takes "<name>", which a tool cannot take | An input named json, or not letters, digits, _ and -. |
It calls <host>. The connection is worn only toward the host of the description or the host that baseUrl names | With auth.oauth2 and no baseUrl, the operation's server is on another host than the description. |
It needs approval and takes intent, an input that Spotlight keeps for the agent to say what it wants to do. To offer it, switch its approval off in <openapi or graphql>.tools.approval | The operation asks a person first and has an input named intent. With its tools.approval set to false it is offered. |
Reading the description
Spotlight reads the description when the integration is created, and keeps what it read. It reads it again only:
- when an update changes
url,document,baseUrl,revision,depth, or whetherauthisoauth2orsecrets; - on any update after a read that failed;
- when the team's connection to the integration is made over OAuth.
A tool call never reads it.
The description is fetched with GET, and a GraphQL schema with an
introspection POST. Spotlight sends auth with the fetch only to the API's
own host: the host of baseUrl for openapi, and of url for graphql.
Without baseUrl, an OpenAPI description is fetched without auth. With
auth.oauth2, the fetch carries the team's connection, even for a requester
integration. The
fetch and every redirect go only to hosts on the deployment's outbound
allow-list. See Limits and timeouts
for the size and time Spotlight allows.
A description that cannot be read does not refuse the definition.
described.problem says why, and the same reason is a warning at
<openapi or graphql>.url, or at .document when that is given. The tools of
the last read that succeeded stay offered; before any such read, none are.
Problems include:
| Problem | Cause |
|---|---|
could not be read: <host> answered <status> | The fetch did not succeed. |
could not be read: <host>: <code> | The host could not be reached, as in ECONNREFUSED or ENOTFOUND. |
could not be read: Outbound call to <host> refused. Host is not on the allowed list. | The fetch, or a redirect, went to a host not on the allow-list. |
could not be read: Outbound call to <host> refused. redirected more than 5 times | Too many redirects. |
could not be read: Outbound call to <host> refused. is where an https request was redirected to over plain http, which Spotlight does not follow | A redirect from https to http. |
is larger than the 64 MB Spotlight reads | The description is too large. |
took longer than 30 seconds to read | The fetch was too slow. |
took longer than 30 seconds to make sense of | Reading the operations was too slow. At most 2 descriptions are read at once on each control plane server; one that waits for its turn waits within the same 30 seconds. |
is not JSON or YAML Spotlight can read: <error> | An OpenAPI description does not parse: as JSON when it starts with { after any leading whitespace, else as YAML with at most 100 aliases. |
is not an OpenAPI description | An OpenAPI description parses to something other than an object. |
could not be made sense of: <error> | Reading the operations of an OpenAPI description or a GraphQL schema failed with an unexpected error, or needed more than the 1,024 MB of memory it is given. |
is a Swagger 2 description. Spotlight reads only OpenAPI 3. Please convert it to OpenAPI 3 | swagger: "2.0". |
is not an OpenAPI 3 description. An OpenAPI 3 description says openapi: 3.0 or 3.1 | No openapi: 3.x. |
names no server to call. Please give the openapi block a baseUrl | No baseUrl, no server, and no url. |
names the server "<server>", which is not a web address. Please give the openapi block a baseUrl | No baseUrl, and the description's server cannot be made a web address. |
is not a GraphQL schema Spotlight can read: <error> | The answer or document is not a GraphQL schema. |
Described
| Field | Terraform | Type | Description |
|---|---|---|---|
kind | kind | string | openapi, graphql or mcp. |
hash | hash | string or null | The SHA-256 of the last read that succeeded. null when none has. |
readAt | read_at | string | When the description was last read, in RFC 3339. |
problem | problem | string or null | Why the latest read failed. null when it succeeded. |
tools | tools | integer | How many of the integration's tools were generated from it. |
An export writes the openapi, graphql or mcp block as part of the
definition, and each secret its auth names as a reference to that
spotlight_secret.
MCP servers
mcp names a remote MCP server. Spotlight lists the server's tools and offers
one tool for each, beside the tools in tools, and makes every call itself.
See Call a remote MCP server.
connection = {
oauth2 = { discover = true }
}
mcp = {
url = "https://mcp.acme.example/mcp"
auth = { oauth2 = { connection = true } }
tools = {
exclude = ["delete-*"]
approval = { "create-comment" = false }
}
}mcp
| Field | Type | Default | Description |
|---|---|---|---|
url | string | required | The server's endpoint. Spotlight speaks MCP's streamable HTTP transport to it. |
auth | object | none | As a request's auth. Sent when Spotlight lists the tools and with every call, only toward the host of url. |
tools | object | {} | Which tools are offered, and which ask first. See Choosing tools. |
Server tools
| Part | From |
|---|---|
| Name | The tool's name in lowercase words joined by dashes: search_issues is search-issues, and createIssue is create-issue. Spotlight calls the server's tool by its own name. |
| Description | The tool's description, else its title, else annotations.title, else Use <tool>. At most 1,000 characters, passed to agents as written. |
| Inputs | Each property of the tool's inputSchema, by its own name, with its JSON Schema. A property in required is a required input. |
| Approval | None for a tool whose annotations.readOnlyHint is true, unless destructiveHint is also true. Every other tool asks a person first, with the reason It changes data in <name>., including a tool with no annotations. tools.approval overrides it. |
| Output | The result's structuredContent. Without it, a single text content, read as JSON when it is JSON and as text when not. Otherwise the whole content array. |
A tool the server lists that gets no tool gives a warning at mcp, as for an
API description: passes over <tool>. <reason>, where the reason is
It has no name that a tool can take, It takes <name>, which a tool cannot take,
Another operation is also called <name>, or a reason its input
schema cannot be read for. The server's resources and prompts are not offered.
Listing the tools
Spotlight lists the tools when the integration is created, and again:
- when an update changes
url, or whetherauthisoauth2; - on any update after a listing that failed;
- when the team's connection to the integration is made over OAuth;
- when the last listing is older than the deployment's
MCP_RELIST_SECONDS, 600 by default, checked every minute. One control plane server claims each such listing, movingdescribed.readAton as it does, and the integration'sversiondoes not change.
The listing carries the team's connection when auth is oauth2, even for a
requester integration. A listing answered 401 renews the connection once
and lists again with the new token, as in Renewal. A listing that fails keeps the tools of the last one that
succeeded, as for a description; see Reading the description.
A listing that succeeds without a tool the integration offered stops offering
it, and the workspace's
configuration changes
record the change, with each tool no longer offered in details.stranded.
Problems a failed listing gives in described.problem include:
| Problem | Cause |
|---|---|
could not be read: <host> answered <status> | The server answered with an HTTP error, as 401 before the team is connected. |
could not be read: <host> answered: <error> | The server answered with an MCP error. |
could not be read: <host>: <code> | The server could not be reached. |
could not be read: Outbound call to <host> refused. Host is not on the allowed list. | The server, or a redirect, is on a host not on the allow-list. |
could not be read: Outbound call to <host> refused. redirected more than 5 times | Too many redirects. |
could not be read: Outbound call to <host> refused. is where an https request was redirected to over plain http, which Spotlight does not follow | A redirect from https to http. |
could not be read: <host> answered without a status | The server's answer failed without an HTTP status. |
Calling a tool
Spotlight keeps MCP sessions for each server, team, integration, kind of
auth, connection holder and access token, and never shares one between
holders. A session carries one call at a time; calls made at once each get a
session of their own. A session unused for 5 minutes is ended at the server
and closed. When the server answers 404 because it has forgotten the
session, Spotlight starts a new one and calls once more. Spotlight opens no
stream for the server to send on outside a call: the GET stream of the
streamable HTTP transport is not opened. A call
answered 401 with auth.oauth2 is renewed and retried once, as in
Renewal.
Spotlight declares no client capabilities and answers no request from the
server other than ping. A server that asks a question in the middle of a
call (MCP elicitation) is refused, and the call fails with
using <tool>: the MCP server asked a question in the middle of the call. Spotlight refused the call. It does not pass such questions on to a person.
A result marked isError fails the call with
using <tool>: the MCP server answered with an error: <text>, cut at 500
characters. An answer over 4 MB fails the call with
it is larger than the 4 MB Spotlight reads from a tool.
On
on maps a task event to a binding. The events are:
| Event | Happens when |
|---|---|
task.opened | A task is opened by a happening from outside Spotlight. |
task.pull-request-opened | A task's pull request is opened. |
task.asked | A task asks a person. |
Binding:
| Field | Type | Default | Description |
|---|---|---|---|
do | string | required | The action to run. It must be defined in actions. |
to | map of name to string | {} | Values the action sees in target. Sees task and ask. Each value is an expression. |
routes | map of name to string | {} | Keyed by line key. When to gives no channel, the value for the task's line is used as target.channel. |
mark | array of objects | [] | Marks kept on the task after the action succeeds. Sees response and capture. find is not read here. |
A binding runs only when target holds a channel or a thread. Otherwise
nothing is sent.
Secrets in expressions
No expression may read a secret. An expression containing secret. or
secret[ is refused with reads a secret's value. No expression may read a secret. Please name the secret in auth.secrets, and Spotlight wears it toward the hosts it is bound to.
Secret references are not checked against the workspace's secrets when the
definition is written. A request that uses a missing secret fails with
Secret <NAME> not found. Please define it first.
Errors and warnings
A definition that does not match the schema, an expression that does not
parse, and a broken rule above are errors: the write is refused with 422
and each problem is listed at its path, as in
webhook.events[0].raise.happening. An expression that parses but does not
type-check is a warning: the definition is kept, and the warning is returned
in warnings. See Type-checking.