Browser automation
The public SDK does not include a browser automation tool. Connect the Playwright, browser-use, Stagehand, or other browser agent you already run on your infrastructure, with your sessions and credentials.
Because custom tools execute in your process, a browser tool is just a handler that drives a browser. The agent decides when to browse and what for; your code decides how, and holds every cookie.
Two shapes#
| Approach | Use when |
|---|---|
| Tool handler | The browser runs in the same process as your SDK code |
| MCP server | The browser runs elsewhere. A separate service, a browser farm |
Start with the tool handler. Move to MCP when the browser needs to scale or live somewhere else.
A goal-shaped tool#
The most important design decision on this page: expose one goal-shaped tool rather than a set of primitives.
A goal-shaped tool can keep browser-specific steps inside your browser implementation rather than exposing every click and scroll to the Coresource agent. Primitive tools are also valid; choose the interface that matches how much control the calling agent should have.
import { createAgent } from '@coresourceai/sdk';
const agent = createAgent({
type: 'agent',
systemPrompt:
'You research suppliers. Use browse_for when information is only available ' +
'on a live web page. State the source URL for every fact you report.',
tools: [
{
name: 'browse_for',
description:
'Visit a web page and extract specific information from it using a real ' +
'browser. Use this for pages that require JavaScript, a login, or ' +
'interaction. Returns the extracted findings and the final URL.',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string', description: 'The page to start from.' },
goal: {
type: 'string',
description:
'What to find or do, in one sentence. e.g. "Find the price of the ' +
'Pro plan billed annually".'
}
},
required: ['url', 'goal'],
additionalProperties: false
},
annotations: { title: 'Browse for information', readOnlyHint: true },
handler: async (input, { signal, deadlineAt }) => {
const remainingMs = deadlineAt - Date.now();
if (remainingMs < 20_000) {
return {
error: 'insufficient_time',
message: 'A browser session needs about 20s. Not enough time remaining.'
};
}
return runBrowserAgent({
url: String(input.url),
goal: String(input.goal),
signal,
timeoutMs: Math.min(remainingMs - 5_000, 120_000)
});
}
}
]
});
await agent.close();
declare function runBrowserAgent(options: {
url: string;
goal: string;
signal: AbortSignal;
timeoutMs: number;
}): Promise<unknown>;Implementing the handler with Playwright#
// no-check: depends on the `playwright` package, which is not a Coresource
// dependency. The tool-handler contract above is the part that is checked.
import type { Browser } from 'playwright';
export type BrowseResult =
| { ok: true; finalUrl: string; title: string; findings: string }
| { ok: false; error: string; message: string };
export async function browseFor(
browser: Browser,
options: { url: string; goal: string; signal: AbortSignal; timeoutMs: number }
): Promise<BrowseResult> {
const context = await browser.newContext();
const page = await context.newPage();
// Close the browser as soon as the run is cancelled, rather than after.
const onAbort = () => void context.close();
options.signal.addEventListener('abort', onAbort, { once: true });
try {
await page.goto(options.url, {
timeout: options.timeoutMs,
waitUntil: 'domcontentloaded'
});
const title = await page.title();
const text = await page.evaluate(() => document.body.innerText);
return {
ok: true,
finalUrl: page.url(),
title,
// Bound what goes back into the context window. A whole page of text
// will crowd out everything the agent has learned so far.
findings: text.slice(0, 8_000)
};
} catch (error) {
return {
ok: false,
error: 'navigation_failed',
message: error instanceof Error ? error.message : String(error)
};
} finally {
options.signal.removeEventListener('abort', onAbort);
await context.close();
}
}Four things this does that matter more than they look:
A fresh context per call. Isolated cookies and storage, so one call cannot poison the next.
Abort wired to teardown. Cancelling the run closes the browser immediately instead of leaving a headless Chromium running.
Bounded output. slice(0, 8_000) is doing real work. Returning a full page of text is the most common way a browser tool degrades an agent. The context fills with navigation chrome and the model loses the thread.
Errors returned, not thrown. A dead link is something the agent can route around. Give it a message it can act on.
Reusing a logged-in session#
The reason to run the browser yourself is usually authentication. Keep the session in your process and never expose the credentials to the model.
// no-check: depends on the `playwright` package, which is not a Coresource
// dependency.
import { chromium, type BrowserContext } from 'playwright';
let sessionContext: BrowserContext | null = null;
/**
* One authenticated context, reused across tool calls. `storageState` is a
* file your login flow wrote once - the agent never sees a credential, and
* cannot ask for one.
*/
export async function getAuthenticatedContext(): Promise<BrowserContext> {
if (sessionContext) {
return sessionContext;
}
const browser = await chromium.launch({ headless: true });
sessionContext = await browser.newContext({
storageState: './.auth/portal-session.json'
});
return sessionContext;
}The agent now interacts with the authenticated browser only through the tool contract. The session file and cookie are not included in the tool arguments or result. Your handler is still responsible for restricting destinations, returned data, and actions.
Constrain where it can go#
An agent that reads web pages can be influenced by page content. Enforce network and action policy in the handler rather than relying on instructions alone.
import type { AgentTool } from '@coresourceai/sdk';
const ALLOWED_HOSTS = new Set([
'docs.example.com',
'status.example.com',
'portal.example.com'
]);
const tool: AgentTool = {
name: 'browse_for',
description: 'Visit an approved page and extract information from it.',
inputSchema: {
type: 'object',
properties: {
url: { type: 'string' },
goal: { type: 'string' }
},
required: ['url', 'goal'],
additionalProperties: false
},
annotations: { readOnlyHint: true },
handler: async (input, { signal }) => {
let target: URL;
try {
target = new URL(String(input.url));
} catch {
return { error: 'invalid_url', message: 'Expected an absolute http(s) URL.' };
}
if (target.protocol !== 'https:') {
return { error: 'insecure_scheme', message: 'Only https URLs are allowed.' };
}
if (!ALLOWED_HOSTS.has(target.hostname)) {
return {
error: 'host_not_allowed',
message: `${target.hostname} is not approved. Allowed: ${[...ALLOWED_HOSTS].join(', ')}.`
};
}
return visitApprovedHosts(target.toString(), String(input.goal), ALLOWED_HOSTS, signal);
}
};
declare function visitApprovedHosts(
url: string,
goal: string,
allowedHosts: ReadonlySet<string>,
signal: AbortSignal
): Promise<unknown>;Guidance that holds up in practice:
- Allowlist every request and redirect. Checking only the initial URL does not stop a page from redirecting or loading a subresource from another host. Enforce the policy inside the browser implementation too.
- Reject non-https. And reject private and loopback addresses unless reaching them is genuinely the job.
- Keep the tool read-only where you can. A separate, explicitly
destructiveHinttool for anything that submits a form makes the risk visible. - Treat returned page text as untrusted. It is data the agent read, not instructions it should follow, and it may contain text engineered to look like instructions.
- Cap the output size. Injection and context exhaustion have the same mitigation.
Adding a screenshot path#
For pages where layout carries the meaning, return a bounded image data URL and let the agent's vision model read it. Setting imageModel enables the hosted understand_image tool, which accepts an image data URL and a prompt.
import { createAgent } from '@coresourceai/sdk';
const agent = createAgent({
type: 'agent',
systemPrompt: 'Use screenshots when a page layout matters, text otherwise.',
model: 'deepseek/deepseek-v4-pro',
imageModel: 'makora/kimi-2.7-code',
outputs: ['findings.md'],
tools: [
{
name: 'screenshot_page',
description:
'Capture a full-page screenshot. Use when layout, charts, or visual ' +
'state matter and the page text is not enough.',
inputSchema: {
type: 'object',
properties: { url: { type: 'string' } },
required: ['url'],
additionalProperties: false
},
annotations: { readOnlyHint: true },
handler: async (input, { signal }) => {
const png = await captureScreenshot(String(input.url), signal);
const image = `data:image/png;base64,${png.toString('base64')}`;
// Every SDK-hosted tool result must fit in the bridge's 1 MiB JSON
// frame. Resize or compress larger captures before returning them.
if (new TextEncoder().encode(image).byteLength > 900_000) {
return {
error: 'image_too_large',
message: 'Capture a smaller region or return a public HTTPS image URL.'
};
}
return { image };
}
}
]
});
await agent.close();
declare function captureScreenshot(url: string, signal: AbortSignal): Promise<Buffer>;Out of process, over MCP#
When the browser lives elsewhere (a pool of workers or a browser-farm service), expose it as an MCP server instead of a handler. If it runs on your network and the SDK process should hold the MCP client, register it locally. A local registration needs a clientFactory; transport alone is metadata and does not create an MCP client:
import { createAgent, type SdkMcpServer } from '@coresourceai/sdk';
const agent = createAgent({ type: 'agent' });
const browserFarm: SdkMcpServer = {
name: 'browser',
namespace: 'browser',
transport: {
kind: 'http',
url: 'https://browser-farm.internal/mcp',
headers: { authorization: `Bearer ${process.env.BROWSER_FARM_TOKEN ?? ''}` }
},
clientFactory: (transport) => createYourMcpClient(transport)
};
agent.registerMcpServer(browserFarm);
await agent.close();
declare function createYourMcpClient(
transport: SdkMcpServer['transport']
): ReturnType<NonNullable<SdkMcpServer['clientFactory']>>;If the service is reachable from the public internet, you can use a hosted connection instead. Its credential is held and injected by Coresource rather than by your SDK process. See Hosted connections.
Checklist#
- One goal-shaped tool, not a set of browser primitives.
- Fresh browser context per call.
context.signalwired to browser teardown.deadlineAtchecked before starting a session.- Output truncated to a few thousand characters.
- Host allowlist applied to the initial URL, redirects, and subresources; https only.
- Page text treated as untrusted data.
- Session credentials are not included in the tool arguments or return value.
