Intercom Fin AI Agent
This integration asks Intercom Fin a question through a fresh, API-created lead and conversation, then returns the AI Agent's reply. You must configure Intercom to route queries to the Fin AI Agent through a custom workflow.
Before testing this setup, make sure you understand the cost for your Intercom account, or confirm that your account is still in its free trial.
Required secrets
| Secret | What it is |
|---|---|
INTERCOM_ACCESS_TOKEN | Intercom REST API access token with permission to create and retrieve conversations. |
Configure Intercom
Complete these steps in Intercom before you create the agent in Spec27. This is also how you satisfy the requirement that queries are routed to Fin through a custom workflow.
1. Add knowledge for Fin
In Intercom, open Knowledge and add the information Fin should use to answer questions about your product or service.
2. Install Intercom on a website
Set up Intercom on a website by including the relevant Intercom JavaScript snippet.
Intercom blocks the relevant APIs until the website integration has been used. If you do not want to install Intercom on a public website, install it on an internal website, interact with it in your browser at least once, and then remove the integration.
3. Create a Fin workflow
Set up a workflow so Fin answers incoming web questions:
- In Intercom, go to Fin AI Agent.
- Open Workflows.
- Select New workflow, then From scratch.
- Choose When customer sends their first message.
- Select Web as the channel.
- Choose Let Fin answer as the next step.
- Optional: disable follow-ups, because Spec27 does not process them.
- Add a fallback message for cases where Fin cannot answer.
- Set the workflow live.
4. Generate an API token
Create the access token Spec27 uses to reach Intercom. It needs permission to create and retrieve conversations.
- In Intercom, open Settings.
- Go to Developer Hub.
- Create a New app.
- Open Authentication.
- Copy the access token.
Store the token as a project secret named
INTERCOM_ACCESS_TOKEN.
Turn support
Multi-turn. The agent keeps a conversation thread alive across turns within an evaluation session, so it suits multi-turn evaluation.
Limitations and notes
- Intercom must be configured to route queries to the Fin AI Agent through a custom workflow — otherwise the conversation will not reach Fin.
- The agent creates a new lead and conversation per case and waits for the AI Agent to respond; slow responses may time out.
How the agent works
The integration you copy from the registry is a single JavaScript function. This walkthrough follows the real code so you can adapt the same pattern to build your own custom agent connection.
The entry point is process(input), where input is the question string for one
evaluation case:
export async function process(input) {
if (typeof input !== "string") {
throw new Error("input must be a question string");
}
const question = input.trim();
if (!question) {
throw new Error("input question is required");
}
// ...
}
Secrets are read from the ambient env object through a small helper that fails
fast when a value is missing, so there is no fallback or default:
const requiredEnv = (key) => {
const value = String(env[key] ?? "").trim();
if (!value) {
throw new Error(`${key} is required`);
}
return value;
};
const token = requiredEnv("INTERCOM_ACCESS_TOKEN");
See the Required secrets table above for what to declare.
All Intercom calls go through one requestJson helper that sets the bearer token,
the Intercom-Version header, an AbortController timeout, and turns non-2xx or
non-JSON responses into thrown errors:
const requestJson = async (token, path, options = {}) => {
// ...
response = await fetch(`${INTERCOM_BASE_URL}${path}`, {
method: options.method ?? "GET",
headers: {
authorization: `Bearer ${token}`,
accept: "application/json",
"content-type": "application/json",
"Intercom-Version": INTERCOM_VERSION,
},
body:
options.body === undefined ? undefined : JSON.stringify(options.body),
signal: controller.signal,
});
// ...
if (!response.ok) {
throw new Error(
`Intercom ${response.status} from ${path}: ${JSON.stringify(body).slice(0, 1000)}`,
);
}
return body;
};
The flow has three steps. First it creates a fresh lead contact with a unique external id:
const createContact = async (token, externalId) => {
const contact = await requestJson(token, "/contacts", {
method: "POST",
body: {
role: "lead",
external_id: externalId,
name: "DSLm Eval Contact",
},
});
// ...
return contact;
};
Then it opens a conversation from that lead with the question as the body, and
captures the returned conversation_id:
const createConversation = async (token, question, contactId) => {
const message = await requestJson(token, "/conversations", {
method: "POST",
body: {
from: { type: "lead", id: contactId },
body: question,
},
});
// ...
return message;
};
Finally it polls the conversation until Fin replies. Retrieval requests
display_as=plaintext and the poll loop calls extractAnswer on each response:
const retrieveConversation = async (token, conversationId) =>
requestJson(
token,
`/conversations/${encodeURIComponent(conversationId)}?display_as=plaintext`,
);
const pollForAnswer = async (token, conversationId) => {
const deadline = Date.now() + POLL_TIMEOUT_MS;
// ...
while (Date.now() <= deadline) {
const conversation = await retrieveConversation(token, conversationId);
const answer = extractAnswer(conversation);
if (answer) {
return { answer, conversation, polls };
}
await sleep(POLL_INTERVAL_MS);
}
// ...
};
Parsing the reply is deliberate: Fin sends follow-up prompts (e.g. "Is that what
you were looking for?") with from_ai_agent=true but is_ai_answer=false, so the
code prefers parts explicitly marked is_ai_answer === true and only falls back to
the first non-contact AI reply when none carry that flag:
const extractAnswer = (conversation) => {
const parts = conversationParts(conversation);
for (const part of parts) {
const answer = isExplicitAiAnswer(part)
? answerFromPart(part, "ai_is_answer_flag")
: null;
if (answer) {
return answer;
}
}
if (conversation.ai_agent_participated === true) {
// ... first non-contact reply
}
return null;
};
Reply bodies arrive as HTML, so answerFromPart keeps both the raw html and a
plaintext rendering produced by htmlToText (which strips tags and decodes
entities). The function returns only the plaintext:
return result.answer.text;
The error-handling convention here is to throw: missing input, missing secret,
HTTP failures, and a poll timeout all raise an Error rather than returning a
status object.
Adapting it for your own agent
To point this same skeleton at a different service, change:
- the base URL, API version header, and the auth scheme in
requestJson(Intercom uses a bearer token); - the request bodies for creating the contact and the conversation to match your service's shapes;
- the response fields you read (
contact.id,conversation_id, theconversation_partsandis_ai_answerselection inextractAnswer); - the polling cadence (
POLL_INTERVAL_MS,POLL_TIMEOUT_MS) and whether you need HTML-to-text conversion at all; - the secret key(s) you read via
requiredEnvand declare in the table above.
Set it up
After configuring Intercom:
- Copy this integration from the registry — see Registry integrations.
- Create the project secret above, using the access token from step 4 — see Project secrets.
- Preview the agent to confirm it works. Messages you submit through the preview should appear in Intercom and be answered automatically by Fin.