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.

KindEndpointTerraformAddress
integration/api/resources/teams/{teamId}/integrationsspotlight_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

FieldTypeDefaultDescription
integrationstringrequiredThe 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.
namestringnoneThe name shown in the web app. Without it, the integration value.
typesmap of name to object{}Named shapes that expressions are type-checked against.
connectionobjectnoneHow the team links to the host. See connection.
webhookobjectnoneDeliveries the host sends. See webhook.
pollobjectnoneA request Spotlight repeats. See poll.
scheduleobjectnoneAn action Spotlight runs on a cron schedule. See schedule.
actionsmap of name to object{}Requests to the host. See actions.
toolsmap of name to object{}Actions offered to agents and hooks. See tools.
openapiobjectnoneAn OpenAPI description to generate tools from. See API descriptions.
graphqlobjectnoneA GraphQL API to generate tools from. See API descriptions.
mcpobjectnoneA remote MCP server whose tools Spotlight lists and calls. See MCP servers.
onmap 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:

FieldTypeDescription
sourcestringEqual to integration.
toolsmap of name to stringEach 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" }.
describedobject or nullThe 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

NamePatternExample
integrationlowercase letters and digits, one word; not catalogacme
action, toollowercase words joined by dashespost-message
tool inputa letter or _, then letters, digits, _ and -, at most 64 characters; not jsonissue, team_id
happeningthe integration's own name, a dot, then dotted words of lowercase letters, digits, _ and -acme.issue.updated
mark kinda source name, a dot, then dotted words as for a happeningacme.issue
secret referencesecret. and a secret name of capitals, digits and _, starting with a capital, at most 64 characterssecret.ACME_API_KEY
type namestarts with a capital, then letters and digitsIssue

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

FieldTypeDefaultDescription
holderstringteamteam or requester. Whose connection the integration's tools act with. See Holder.
oauth2.discoverbooleanfalseFind the endpoints, the registration endpoint and the scopes from what the MCP server named in mcp.url publishes. See Discovery.
oauth2.authorizestringrequired unless discoverThe host's authorization endpoint.
oauth2.tokenstringrequired unless discoverThe host's token endpoint.
oauth2.scopesarray of strings[]Scopes asked for, joined by spaces.
oauth2.identitystringnoneAn expression. Names the linked account, shown as "Connected as" in the web app. Sees response, the token endpoint's response.
oauth2.registrationstringnoneA 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.resourcestringnoneA URL. Sent as the resource parameter (RFC 8707) on the authorization URL and on every token request.
manual.secretsarray 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

HolderA tool acts withPolls, schedules and on bindings act with
teamThe team's connection.The team's connection.
requesterThe 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.

SituationResult
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.

StepEndpointBehaviour
BeginPOST /api/teams/{teamId}/integrations/{source}/connectAn 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.
CallbackGET /api/integrations/oauth/callbackNeeds 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.
ListGET /api/teams/{teamId}/connectionsNeeds 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.
DisconnectDELETE /api/teams/{teamId}/integrations/{source}/connections/mineNeeds 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 rulePUT /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

WhenWhat 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:

  1. It sends the server an initialize request without credentials. A 401 whose WWW-Authenticate names resource_metadata points at the server's protected resource metadata (RFC 9728). Otherwise Spotlight reads /.well-known/oauth-protected-resource with the server's path after it, and then at the root.
  2. 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 fieldComes from
authorizeauthorization_endpoint
tokentoken_endpoint
registrationregistration_endpoint, when the authorization server has one. See Client registration.
scopesoauth2.scopes when set, else the protected resource metadata's scopes_supported.
resourceoauth2.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.

ClientBound by
Registered at registration_endpointSpotlight 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 integrationIts 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

FieldTypeDefaultDescription
shapeobjectnoneThe shape of body, for type-checking.
verifyobjectrequiredHow a delivery is verified. Exactly one of hmac, token, asymmetric or jwt. See verify.
respondobjectnoneA 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.
minestringnoneAn expression that returns bool. When it holds, the delivery is the host echoing Spotlight's own post, and its happenings are dropped.
routeobjectnoneby (expression). Type-checked. Deliveries go to the integration's team whatever it returns.
parsestringjsonjson or form. Kept in the definition. Not used when a delivery is read.
deliverystringnoneAn 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.
eventsarray of objectsrequiredAt 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>
StatusWhen
200 with the respond.body textThe delivery is verified and respond.when holds.
204The delivery is verified and accepted. It is read afterwards.
401The 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:

FieldTypeDefaultDescription
algorithmstringsha256sha256, sha1 or sha512. The HMAC digest.
secretstringrequiredThe signing secret. A secret reference, secret.<NAME>.
signedstringnoneAn expression. The text that was signed. Sees body, headers and raw. Without it, the raw body.
encodingstringhexhex or base64. How the signature is written.
prefixstring""Text written before the signature, as in v0=.
headerstringnoneThe header that carries the signature. A delivery without it is refused, and without header no delivery verifies.
freshness.atstringnoneAn expression. The delivery's time in Unix seconds. A delivery whose time cannot be read is refused. Required with freshness.
freshness.withinstringnoneSeconds, written as 300s. A delivery further than this from now is refused. Required with freshness.

token:

FieldTypeDefaultDescription
secretstringrequiredThe shared token. A secret reference, secret.<NAME>.
headerstringnoneThe header that carries it. Compared whole. A delivery without it is refused, and without header no delivery verifies.

asymmetric:

FieldTypeDefaultDescription
algorithmstringrequireded25519 or rsa-sha256. The signature algorithm.
keystringrequiredThe public key: hex of the raw key for ed25519, PEM for rsa-sha256. May hold expressions.
signedstringnoneAn expression. The text that was signed. Without it, the raw body.
encodingstringhexhex or base64. How the signature is written.
headerstringnoneThe header that carries the signature. A delivery without it is refused, and without header no delivery verifies.
freshnessobjectnoneAs for hmac.

jwt:

FieldTypeDefaultDescription
jwksstringrequiredWhere the signing keys are published.
headerstringauthorizationThe header that carries the token. A Bearer prefix is removed.
audiencestringnoneWhen set, the token's aud must hold it.
issuerstringnoneWhen 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.

FieldTypeDefaultDescription
forEachstringnoneAn expression that returns a list. The event is read once for each item, which expressions see as item.
whenstringrequiredAn expression that returns bool. The event raises only when it holds. An expression that fails to evaluate counts as false.
lookup.requestobjectnoneA further request made before raising. See requests. When it does not succeed, the event raises without the captures.
lookup.capturemap of name to stringnoneValues read from the lookup's response. Raise expressions see each by its name. Each value is an expression. Required with lookup.
raiseobjectrequiredSee raise.

Raise

FieldTypeDefaultDescription
happeningstringrequiredThe happening's name. Begins with the integration's name.
labelstringnoneWhat a person picking a happening reads. Without it, the happening's name.
toldstringnoneHow the happening reads in a note on the task. Without it, the happening's name.
spokenbooleanfalseWhether the happening carries words to the task.
saystringnoneAn expression. The words carried.
opensobjectnoneOpens a task when no task is found. See below.
branchstringnoneAn expression. A branch name that finds a task.
marksarray of objects[]Marks that find a task, or are kept on the task this opens.
attachesobjectnoneFiles 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:

FieldTypeDefaultDescription
kindstringrequiredThe mark kind, source first.
valuestringrequiredAn expression. The mark's value. A mark whose value is empty is not kept.
findbooleantruetrue: the mark finds the task the happening is about. false: the task this opens keeps it, and it never finds a task.

opens:

FieldTypeDefaultDescription
ontostringrequiredThe role of the line to open on, in lowercase words joined by dashes.
atstringnoneThe key of the station to open at. Without it, or with a key the line does not have, the line's first work station.
titlestringrequiredAn expression. The task's title. No task opens when it is empty.
descriptionstringnoneAn expression. The task's description.
assignBy.emailstringnoneAn 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:

FieldTypeDefaultDescription
eachstringrequiredAn expression that returns a list, one entry per file. Later fields see each entry as file.
namestringrequiredAn expression. The file name. An entry with no name is skipped.
typestringnoneAn expression. The content type.
sizestringnoneAn expression. The size in bytes.
fetchobjectrequiredThe request that downloads the file.

Poll

FieldTypeDefaultDescription
shapeobjectnoneThe shape of body, for type-checking.
everystringrequiredWhole seconds between polls, written as 60s.
requestobjectrequiredThe request made each time. Sees cursor and connection.
cursorstringnoneAn expression. The next cursor, read from the response. Sees body and cursor.
eventsarray of objectsrequiredAt least one. Read against the response, which expressions see as body.

A poll whose request is refused raises nothing.

Schedule

FieldTypeDefaultDescription
cronstringrequiredA cron expression: five fields, or six with seconds first.
dostringrequiredThe 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.

FieldTypeDefaultDescription
responseobjectnoneThe shape of response, for type-checking.
methodstringPOSTGET, POST, PUT, PATCH or DELETE. The HTTP method.
urlstringrequiredThe address. May hold expressions.
querymap of name to string{}Query parameters. A parameter that evaluates to nothing is left out. Values may hold expressions.
headersmap of name to string{}Request headers. A header that evaluates to nothing is left out. Values may hold expressions.
bodystring, array or objectnoneA 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.
authobjectnoneExactly one of oauth2 or secrets. See below.
expect.successstringnoneAn expression that returns bool. When set, decides whether the request succeeded.
expect.statusarray of integersnoneStatuses that count as success, when expect.success is not set.
capturemap of name to stringnoneValues read from the response. Each value is an expression.
retryobjectnonetimes (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:

FieldTypeDefaultDescription
oauth2.connectionbooleantrueMust 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.
secretsarray of stringsnoneSecret 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

FieldTypeDefaultDescription
doesstringrequiredThe action the tool runs. It must be defined in actions.
descriptionstringrequiredWhen an agent should use the tool. May not be empty.
approval.becausestringnoneWhen 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.
inputsmap of name to object{}The tool's inputs.
outputstringnoneAn expression. What the tool returns. Sees response, status, capture and inputs. Without it, the capture map.

Input:

FieldTypeDefaultDescription
typestringstringstring, number, integer or boolean. The input's type. Expressions see boolean as bool, and number and integer as dyn.
schemaobjectnoneA 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.
descriptionstringnoneShown to the agent.
requiredbooleanfalseWhether the agent must give it.
defaultstringnoneThe value when none is given. Sees task. May hold expressions.
pipedbooleanfalseThe 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:

MessageCause
<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

FieldTypeDefaultDescription
urlstringnoneWhere the description is read from, as JSON or YAML. Exactly one of url or document.
documentobjectnoneThe description written out in full.
baseUrlstringnoneWhere 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.
revisionstringnoneAny text. A change makes Spotlight read the description again.
authobjectnoneAs a request's auth. Sent with every generated call, and with the fetch of the description when it is on the host of baseUrl.
toolsobject{}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

FieldTypeDefaultDescription
urlstringrequiredThe endpoint. Generated calls are sent to it as POST. Without document, Spotlight reads the schema by asking the endpoint for it (introspection).
documentstringnoneThe schema in GraphQL's schema language, read in place of asking the endpoint.
depthinteger21 to 5. How many levels of fields a generated call selects in its answer.
revisionstringnoneAny text. A change makes Spotlight read the schema again.
authobjectnoneAs a request's auth. Sent when Spotlight asks for the schema, and with every generated call.
toolsobject{}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

FieldTypeDefaultDescription
includearray of strings[]Patterns. When set, only operations whose tool name matches one are offered.
excludearray of strings[]Patterns. Operations whose tool name matches one are not offered.
approvalmap 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:

PartFrom
NameThe 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.
DescriptionThe 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.
InputsThe 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.
ApprovalOpenAPI 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.
OutputFor 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>:

ReasonCause
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 takeIts operationId, method and path, or field name give no tool name.
It needs its <in> parameter <name>, which a tool cannot sendA 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 readA parameter is not an object with name and in, or its $ref points nowhere.
It takes <name> twiceTwo parameters in different places share a name.
It takes a parameter called body as well as a bodyA parameter named body beside a JSON request body.
It sends a body that is not JSON. A tool cannot send such a body yetA required request body with no JSON media type.
It takes <name>, which a tool cannot takeA GraphQL argument whose name a tool input cannot have.
It takes "<name>", which a tool cannot takeAn 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 namesWith 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.approvalThe 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 whether auth is oauth2 or secrets;
  • 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:

ProblemCause
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 timesToo 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 followA redirect from https to http.
is larger than the 64 MB Spotlight readsThe description is too large.
took longer than 30 seconds to readThe fetch was too slow.
took longer than 30 seconds to make sense ofReading 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 descriptionAn 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 3swagger: "2.0".
is not an OpenAPI 3 description. An OpenAPI 3 description says openapi: 3.0 or 3.1No openapi: 3.x.
names no server to call. Please give the openapi block a baseUrlNo baseUrl, no server, and no url.
names the server "<server>", which is not a web address. Please give the openapi block a baseUrlNo 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

FieldTerraformTypeDescription
kindkindstringopenapi, graphql or mcp.
hashhashstring or nullThe SHA-256 of the last read that succeeded. null when none has.
readAtread_atstringWhen the description was last read, in RFC 3339.
problemproblemstring or nullWhy the latest read failed. null when it succeeded.
toolstoolsintegerHow 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

FieldTypeDefaultDescription
urlstringrequiredThe server's endpoint. Spotlight speaks MCP's streamable HTTP transport to it.
authobjectnoneAs a request's auth. Sent when Spotlight lists the tools and with every call, only toward the host of url.
toolsobject{}Which tools are offered, and which ask first. See Choosing tools.

Server tools

PartFrom
NameThe 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.
DescriptionThe tool's description, else its title, else annotations.title, else Use <tool>. At most 1,000 characters, passed to agents as written.
InputsEach property of the tool's inputSchema, by its own name, with its JSON Schema. A property in required is a required input.
ApprovalNone 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.
OutputThe 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 whether auth is oauth2;
  • 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, moving described.readAt on as it does, and the integration's version does 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:

ProblemCause
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 timesToo 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 followA redirect from https to http.
could not be read: <host> answered without a statusThe 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:

EventHappens when
task.openedA task is opened by a happening from outside Spotlight.
task.pull-request-openedA task's pull request is opened.
task.askedA task asks a person.

Binding:

FieldTypeDefaultDescription
dostringrequiredThe action to run. It must be defined in actions.
tomap of name to string{}Values the action sees in target. Sees task and ask. Each value is an expression.
routesmap 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.
markarray 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.

On this page