Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
274 changes: 274 additions & 0 deletions src/content/docs/cookbooks/fastrouter-agentkit-tool-calling.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,274 @@
---
title: 'Route LLM calls through FastRouter with Scalekit AgentKit tools'
description: 'Build a Node.js agent that uses FastRouter as its LLM provider and Scalekit AgentKit for per-user OAuth-connected tools — Gmail, GitHub, Slack, and more — without writing OAuth code per integration.'
Comment on lines +2 to +3
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Frontmatter title/description exceed documented limits.

title is over 60 characters and description is over 160 characters. Please shorten both to meet docs metadata constraints.

Suggested edit
-title: 'Route LLM calls through FastRouter with Scalekit AgentKit tools'
-description: 'Build a Node.js agent that uses FastRouter as its LLM provider and Scalekit AgentKit for per-user OAuth-connected tools — Gmail, GitHub, Slack, and more — without writing OAuth code per integration.'
+title: 'Build a FastRouter AgentKit tool-calling agent'
+description: 'Build a Node.js agent that routes FastRouter LLM calls and executes per-user OAuth-connected tools through Scalekit AgentKit.'

As per coding guidelines: “title MUST be ≤ 60 characters” and “description MUST be ≤ 160 characters.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/content/docs/cookbooks/fastrouter-agentkit-tool-calling.mdx` around lines
2 - 3, The frontmatter in this MDX file has a title and description that exceed
docs limits; shorten the frontmatter fields `title` (must be ≤ 60 chars) and
`description` (must be ≤ 160 chars) in the top-level YAML/MDX frontmatter block
so they comply; update the `title: 'Route LLM calls through FastRouter with
Scalekit AgentKit tools'` to a concise variant under 60 characters and shorten
`description: 'Build a Node.js agent that uses FastRouter as its LLM provider
and Scalekit AgentKit for per-user OAuth-connected tools — Gmail, GitHub, Slack,
and more — without writing OAuth code per integration.'` to a single-line
summary under 160 characters, preserving key intent (FastRouter + Scalekit
AgentKit + OAuth tools).

date: 2026-05-26
tags: ['Agent auth', 'Gmail', 'FastRouter', 'Tool calling', 'AgentKit']
sidebar:
label: 'Tool calling with FastRouter'
tableOfContents: true
excerpt: >
Connect FastRouter's OpenAI-compatible API to per-user OAuth tools via Scalekit AgentKit. The agent discovers available tools, runs an agentic loop through FastRouter, and executes each tool call via Scalekit — no per-integration OAuth code required.
featured: false
authors:
- name: 'Saif'
title: 'Developer Advocate'
url: 'https://www.linkedin.com/in/saif-shines/'
picture: '/images/blog/authors/saif.png'
---

import { Aside, Steps, Tabs, TabItem } from '@astrojs/starlight/components';

Build an agent that routes LLM calls through [FastRouter](https://fastrouter.ai) and executes OAuth-connected tools through Scalekit AgentKit. FastRouter provides an OpenAI-compatible chat completions API, so the integration requires only one configuration change: point the OpenAI SDK's `baseURL` at FastRouter. Scalekit handles OAuth token storage, tool discovery, and tool execution for every connected service.

The sample repository is **[fastrouter-scalekit-demo](https://github.com/scalekit-developers/fastrouter-scalekit-demo)** on GitHub.

## What you are building

- **FastRouter as the LLM provider** — All chat completions go through FastRouter's OpenAI-compatible endpoint. Switch models by changing one environment variable.
- **Scalekit AgentKit for tool access** — `listScopedTools` returns per-user tool schemas ready to pass directly to FastRouter. `executeTool` runs each tool server-side and returns structured results.
- **B2B OAuth without custom OAuth code** — Scalekit handles the OAuth flow, token storage, and refresh for each connected service. Your agent gets an auth link, waits for the user to authorize, and receives a verified, active connected account.
- **Agentic loop** — The agent calls FastRouter, receives tool calls, executes them through Scalekit, and feeds results back — repeating until FastRouter returns a final answer.

## Prerequisites

- Scalekit account with AgentKit enabled — [create one at app.scalekit.com](https://app.scalekit.com)
- At least one AgentKit connection configured (Gmail, GitHub, or Slack)
- FastRouter account and API key — [sign up at fastrouter.ai](https://fastrouter.ai)
- Node.js 20 or later

## Clone and run the sample

<Steps>

1. **Clone the repository and install dependencies.**

```sh
git clone https://github.com/scalekit-developers/fastrouter-scalekit-demo
cd fastrouter-scalekit-demo
npm install
```

2. **Copy the example environment file and fill in your credentials.**

```sh
cp .env.example .env
```

Open `.env` and set these values:

```sh
# Scalekit — find these in your Scalekit dashboard under API Keys
SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.dev
SCALEKIT_CLIENT_ID=your_client_id
SCALEKIT_CLIENT_SECRET=your_client_secret

# The AgentKit connection to use — must match a connection name in your dashboard
SCALEKIT_CONNECTION_NAME=gmail

# FastRouter — find your API key at fastrouter.ai/dashboard
FASTROUTER_API_KEY=sk-v1-...
FASTROUTER_MODEL=openai/gpt-4o-mini
```

`SCALEKIT_CONNECTION_NAME` must match the exact connection name in your Scalekit dashboard under **AgentKit → Connections**.

3. **Run the agent.**

```sh
npm start
```

4. **Authorize the connection on first run.**

The agent prints an authorization link if the connected account is not yet active:

```
Authorization required.
Open this link and complete the flow:

https://your-env.scalekit.dev/magicLink/...

Waiting for callback on http://localhost:3000/callback ...
```

Open the link in your browser and complete the OAuth flow. The agent detects the callback automatically and continues — no manual step required.

</Steps>

After authorization, the agent loads tools, calls FastRouter, and prints a final answer:

```
Connected account is now active.
Loaded 17 scoped tools from Scalekit.
Model requested 1 tool call(s).

→ Executing gmail_list_messages
args: {"maxResults":5,"q":"is:unread"}

Final answer:

Here are your 5 most recent unread emails: ...
```

## How the agent works

Three pieces connect FastRouter to Scalekit tools.

### B2B OAuth connects user accounts without custom token code

Scalekit handles the full OAuth flow. Your agent calls `getOrCreateConnectedAccount` to check whether the user's account is already connected, then calls `getAuthorizationLink` to get an auth URL if it isn't.

```typescript
const userVerifyUrl = 'http://localhost:3000/callback';

const { connectedAccount } = await scalekit.actions.getOrCreateConnectedAccount({
connectionName: 'gmail',
identifier: 'user_123',
userVerifyUrl,
});

if (connectedAccount?.status !== ConnectorStatus.ACTIVE) {
const { link } = await scalekit.actions.getAuthorizationLink({
connectionName: 'gmail',
identifier: 'user_123',
userVerifyUrl,
});
// Show link to user, wait for callback
}
```

`userVerifyUrl` is where Scalekit redirects after the OAuth flow completes. The sample runs a minimal HTTP server on `localhost:3000` to catch that redirect, extract the `auth_request_id` parameter, and call `verifyConnectedAccountUser` to mark the account active:

```typescript
async function waitForCallback(port: number): Promise<string> {
return new Promise((resolve, reject) => {
const server = http.createServer((req, res) => {
const url = new URL(req.url ?? '/', `http://localhost:${port}`);
const authRequestId = url.searchParams.get('auth_request_id');
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end('<html><body><h2>Authorization complete — return to your terminal.</h2></body></html>');
server.close();
if (authRequestId) resolve(authRequestId);
else reject(new Error('No auth_request_id in callback'));
});
server.listen(port);
});
}

const authRequestId = await waitForCallback(3000);
await scalekit.actions.verifyConnectedAccountUser({
authRequestId,
identifier: 'user_123',
});
```
Comment on lines +121 to +163
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

SDK usage examples should include Node.js, Python, Go, and Java tabs.

These snippets demonstrate Scalekit SDK operations (getOrCreateConnectedAccount, getAuthorizationLink, verifyConnectedAccountUser, listScopedTools, executeTool) but only provide Node.js. Add <Tabs syncKey="tech-stack"> with all four languages for these SDK examples.

As per coding guidelines: “Every code block demonstrating an SDK operation must include all four languages (Node.js, Python, Go, Java) using synced tabs with syncKey='tech-stack'.”

Also applies to: 173-190, 198-237, 263-267

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/content/docs/cookbooks/fastrouter-agentkit-tool-calling.mdx` around lines
121 - 163, Update the SDK example blocks to use a Tabs component with
syncKey="tech-stack" and provide equivalent code snippets for Node.js, Python,
Go, and Java for each SDK operation shown (getOrCreateConnectedAccount,
getAuthorizationLink, verifyConnectedAccountUser, listScopedTools, executeTool);
keep the existing Node.js examples (including userVerifyUrl and waitForCallback
usage) and add parallel Python/Go/Java snippets that demonstrate the same
sequence (creating/obtaining connected account, retrieving authorization link,
waiting for callback/auth_request_id, calling verifyConnectedAccountUser, and
any listScopedTools/executeTool calls) so every demonstrated SDK operation has
four-language tabs synchronized via syncKey="tech-stack".

Comment on lines +140 to +163
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | ⚡ Quick win

OAuth callback example should validate state and correct callback semantics.

The sample verifies only auth_request_id; it should also validate state to mitigate CSRF and account-mixup risk. Also, Line 166 says Scalekit “posts” auth_request_id; this flow is shown elsewhere in docs as a redirect with query params.

Suggested edit
-const authRequestId = await waitForCallback(3000);
+const { authRequestId, state } = await waitForCallback(3000);
+if (state !== expectedStateFromSession) {
+  throw new Error('Invalid OAuth state');
+}
 await scalekit.actions.verifyConnectedAccountUser({
   authRequestId,
   identifier: 'user_123',
 });

As per coding guidelines: “Explain security implications when relevant” and include security-sensitive handling; based on learnings, callback verification should include both auth_request_id and state.

Also applies to: 166-166

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/content/docs/cookbooks/fastrouter-agentkit-tool-calling.mdx` around lines
140 - 163, The example OAuth callback handler (waitForCallback) only extracts
auth_request_id and lacks validation of the OAuth state, which exposes
CSRF/account-mixup risk; update waitForCallback to also read and validate the
state query parameter against the original state value from the initiating
request (ensure the example shows where the original state is generated and
passed to userVerifyUrl), only resolve when both auth_request_id and state match
expected values, return a clear error otherwise, and then pass the authRequestId
into scalekit.actions.verifyConnectedAccountUser as shown; also correct the
prose to describe the flow as a redirect with query params (not a POST) and
mention the security check.


<Aside type="tip">
In a production web app, replace `localhost:3000/callback` with your server's callback endpoint. Scalekit posts the `auth_request_id` there, and your handler calls `verifyConnectedAccountUser` to complete account activation.
</Aside>
Comment on lines +165 to +167
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Add a title to the Aside component.

<Aside type="tip"> should include a title prop for accessibility and consistency.

Suggested edit
-<Aside type="tip">
+<Aside type="tip" title="Production callback handling">

As per coding guidelines: “Always include a title attribute for <Aside> components for accessibility and clarity.”

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<Aside type="tip">
In a production web app, replace `localhost:3000/callback` with your server's callback endpoint. Scalekit posts the `auth_request_id` there, and your handler calls `verifyConnectedAccountUser` to complete account activation.
</Aside>
<Aside type="tip" title="Production callback handling">
In a production web app, replace `localhost:3000/callback` with your server's callback endpoint. Scalekit posts the `auth_request_id` there, and your handler calls `verifyConnectedAccountUser` to complete account activation.
</Aside>
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/content/docs/cookbooks/fastrouter-agentkit-tool-calling.mdx` around lines
165 - 167, The Aside component instance using <Aside type="tip"> needs an
explicit title prop for accessibility; update that JSX to include a descriptive
title (e.g., title="Tip" or title="Production callback endpoint") so it reads
<Aside type="tip" title="...">, ensuring the title accurately describes the
content about replacing localhost:3000/callback and the auth_request_id handler.


### Tool discovery returns schemas in FastRouter's expected format

`listScopedTools` returns only the tools the connected account has permission to use. Map each tool's `input_schema` to the `parameters` field FastRouter expects:

```typescript
const { tools } = await scalekit.tools.listScopedTools('user_123', {
filter: { connectionNames: ['gmail'] },
pageSize: 100,
});

const fastRouterTools = tools
.map((t) => t.tool?.definition)
.filter((def): def is NonNullable<typeof def> => Boolean(def?.name))
.map((def) => ({
type: 'function' as const,
function: {
name: String(def.name),
description: String(def.description ?? ''),
parameters: def.input_schema ?? { type: 'object', properties: {} },
},
}));
```

FastRouter uses the same function-calling format as OpenAI. No additional schema transformation is needed.

### The agentic loop runs until the model stops requesting tools

Pass the tool list to FastRouter and execute each tool call through Scalekit until the model returns a response with no tool calls:

```typescript
const messages: OpenAI.ChatCompletionMessageParam[] = [
{ role: 'system', content: 'You are a helpful assistant. Use tools when they help. Do not invent tool results.' },
{ role: 'user', content: 'Fetch my last 5 unread emails and summarize them.' },
];

for (let turn = 0; turn < 8; turn++) {
const response = await fastRouter.chat.completions.create({
model: 'openai/gpt-4o-mini',
messages,
tools: fastRouterTools,
tool_choice: 'auto',
});

const message = response.choices[0].message;
messages.push(message);

// No tool calls means a final answer
if (!message.tool_calls?.length) {
console.log(message.content);
return;
}

// Execute each tool call and append the result
for (const call of message.tool_calls) {
const result = await scalekit.actions.executeTool({
toolName: call.function.name,
identifier: 'user_123',
connector: 'gmail',
toolInput: JSON.parse(call.function.arguments),
});

messages.push({
role: 'tool',
tool_call_id: call.id,
content: JSON.stringify(result.data ?? {}),
});
}
}
```

`executeTool` runs the tool server-side using the connected account's stored OAuth tokens. Your agent never handles raw access tokens.

## Customize the agent

**Change the connection.** Set `SCALEKIT_CONNECTION_NAME` to any connection configured in your Scalekit dashboard:

| Value | What it connects |
|-------|-----------------|
| `gmail` | Gmail read/send |
| `github` | Repositories, issues, pull requests |
| `slack` | Channels, messages, users |

**Change the model.** Set `FASTROUTER_MODEL` in `.env` to any model FastRouter supports. The agent uses the same code regardless of which model you choose.

**Change the prompt.** Pass a prompt as a CLI argument to override the default:

```sh
npm start "List all GitHub pull requests assigned to me"
```

Or set `USER_PROMPT` in `.env` to change the default.

**Support multiple connections.** Call `listScopedTools` with multiple connection names to give the model tools from all of them at once:

```typescript
const { tools } = await scalekit.tools.listScopedTools('user_123', {
filter: { connectionNames: ['gmail', 'github', 'slack'] },
});
```

## Next steps

- **[Scalekit AgentKit overview](/agentkit)** — Understand connected accounts, tool discovery, and tool execution in depth.
- **[AgentKit connections](/agentkit/connectors)** — Set up Gmail, GitHub, Slack, and other connections.
- **[OpenAI example](/agentkit/examples/openai)** — See the same tool-calling pattern with OpenAI directly.
- **[LiteLLM inbox triage cookbook](/cookbooks/litellm-agentkit-inbox-triage)** — A more complex multi-connection agent with a web approval interface.