@flow-state-dev/tools
v0.1.3
Published
Reusable tool blocks for flow-state-dev.
Downloads
55
Maintainers
Readme
@flow-state-dev/tools
Portable tool blocks for @flow-state-dev flows. Each tool is a handler block that can be passed directly to a generator's tools array.
Installation
pnpm add @flow-state-dev/toolsSearch
Multi-provider web search with automatic provider detection.
import { search } from "@flow-state-dev/tools/search";
const webSearch = search(); // auto-detects from env vars
generator({
tools: [webSearch],
// ...
});Providers
Provider is selected automatically based on available API keys (checked in order):
| Provider | Env var | Package | Result type |
|----------|---------|---------|-------------|
| Parallel | PARALLEL_API_KEY | (fetch-based, no extra dep) | Raw results |
| Tavily | TAVILY_API_KEY | @tavily/core (optional peer dep) | Raw results |
| Exa | EXA_API_KEY | exa-js (optional peer dep) | Raw results |
| Perplexity | PERPLEXITY_API_KEY | (fetch-based, no extra dep) | Raw results |
| Serper | SERPER_API_KEY | (fetch-based, no extra dep) | Raw results |
| Brave | BRAVE_SEARCH_API_KEY | (fetch-based, no extra dep) | Raw results |
| Perplexity Sonar | PERPLEXITY_API_KEY | (fetch-based, no extra dep) | Grounded answer + citations |
Perplexity Search API returns raw ranked web results (hybrid lexical + semantic retrieval). Perplexity Sonar returns AI-synthesized answers with source citations, similar to Gemini grounding. When PERPLEXITY_API_KEY is set, auto-detection prefers the Search API. Use perplexitySonarSearch() to explicitly select the Sonar grounding provider.
Configuration
search({
provider: "tavily", // override auto-detection
maxResults: 10, // default: 5
tier: "deep", // "fast" | "balanced" (default) | "deep" — retrieval thoroughness
agentControlsTier: true, // expose `tier` to the model so it picks depth per query
searchDepth: "advanced", // "basic" (default) or "advanced" — content pulled per result
searchMode: "neural", // provider-native override of the tier mapping (e.g. Exa type)
includeDomains: ["arxiv.org"], // restrict to these domains (where supported)
excludeDomains: ["pinterest.com"], // exclude these domains (where supported)
topic: "news", // "general" (default) or "news"
keys: { tavily: "sk-..." }, // explicit keys (default: env vars)
});tier is a provider-agnostic latency-vs-thoroughness knob mapped to each provider's native parameter. When several providers are configured, auto-selection prefers one that supports the requested tier (Serper and Brave have no deep mode, so a deep request routes past them). balanced reproduces the previous default behavior. For provider-specific behaviors the tier doesn't cover, searchMode overrides the native value directly. See the search tool docs for the full per-provider mapping.
tools.search is distinct from the generator's built-in search option. tier is tools.search-only and does not apply to generator-native search; the two searchDepth fields also differ (here "basic" | "advanced" for content per result, versus the generator's "low" | "medium" | "high"). See Web search.
Direct provider constructors
import {
tavilySearch,
exaSearch,
perplexitySearch,
serperSearch,
braveSearch,
parallelSearch,
perplexitySonarSearch,
} from "@flow-state-dev/tools/search";Fetch
Fetch a single web page and return its content as clean, LLM-ready markdown.
import { fetch } from "@flow-state-dev/tools/fetch";
const pageFetch = fetch(); // auto-detects from env vars
generator({
tools: [pageFetch],
// ...
});Providers
| Provider | Env var | Package |
|----------|---------|---------|
| Firecrawl | FIRECRAWL_API_KEY | @mendable/firecrawl-js (optional peer dep) |
| Jina Reader | JINA_API_KEY (optional) | (fetch-based, no extra dep) |
| Built-in | (none needed) | (uses Readability + Turndown) |
Always works — falls back to built-in when no API keys are set.
Direct provider constructors
import { firecrawlFetch, jinaFetch, builtinFetch } from "@flow-state-dev/tools/fetch";Public addresses only
The built-in provider opens a socket from your server to whatever URL the model
picked, so it reaches publicly routable addresses only. Loopback, link-local
(including cloud metadata endpoints such as 169.254.169.254), private, and
reserved ranges are rejected before the request is made, and every redirect hop
is checked the same way. A blocked URL throws a FlowError with code
fetch_blocked_url and error.details.errorType of "blocked", never
retryable — the refusal is a property of the URL, so retrying only refuses
again. A DNS failure while checking is still a normal retryable "network"
error.
Hosted providers are unaffected — Firecrawl and Jina call a vendor API rather than the target, so nothing on your network is reachable through them.
To fetch an intranet page on purpose, do it in your own handler rather than through this tool. One caveat worth knowing: the check resolves the hostname before connecting, and the connection resolves it again, so a hostile DNS server that answers differently each time is not covered.
Error details
When a fetch fails, the tool throws a FlowError whose error.details carries errorType ("http" / "network" / "timeout" / "abort" / "parse"), plus httpStatus, httpStatusText, and a truncated responseBody for HTTP failures, and the underlying cause for transport failures. retryable is set per class — 5xx / network / timeout are retryable, 4xx is not — so a generator's built-in retry handles transient failures for you. See Error handling.
Crawl
Crawl a website starting from a root URL, following links breadth-first.
import { crawl } from "@flow-state-dev/tools/crawl";
const siteCrawl = crawl({ maxPages: 30, maxDepth: 2 });
generator({
tools: [siteCrawl],
// ...
});Providers
| Provider | Env var | Package |
|----------|---------|---------|
| Firecrawl | FIRECRAWL_API_KEY | @mendable/firecrawl-js (optional peer dep) |
| Built-in | (none needed) | (BFS crawler with Readability + Turndown) |
Always works — falls back to built-in BFS crawler when no API keys are set.
Direct provider constructors
import { firecrawlCrawl, builtinCrawl } from "@flow-state-dev/tools/crawl";Public addresses only
The built-in crawler follows the same rule as the built-in fetch provider, and
it applies to every link it discovers, not just the root URL — a public page
linking to http://127.0.0.1/ will not walk the crawler onto your host. Blocked
URLs are skipped like any other unreachable page, so a crawl completes with the
pages it could legitimately read.
Bash
Resource-backed bash execution with pluggable sandbox adapters. Files live as framework resources for persistence and portability. They're materialized into a real filesystem for execution, then synced back after mutations.
import { createBashTool } from "@flow-state-dev/tools/bash";
import { providerTool } from "@flow-state-dev/core";
// Inside a handler's execute function:
const { tools, sandbox } = await createBashTool({
collections: { files: ctx.resources.files },
provider: { type: "local", cwd: "./workspace" },
});
// Pass to a generator as provider tools:
generator({
providerTools: [
providerTool("bash", tools.bash),
providerTool("readFile", tools.readFile),
providerTool("writeFile", tools.writeFile),
],
});Sandbox adapters
| Adapter | Provider type | Description |
|---------|--------------|-------------|
| Local FS | "local" | Real filesystem + child_process. Best for development. |
| Vercel | "vercel" | @vercel/sandbox. Supports persistent sandboxes. Requires OIDC Federation enabled on the project or the VERCEL_TOKEN + VERCEL_TEAM_ID + VERCEL_PROJECT_ID triple. Without either, the adapter throws a clear error naming both options — pick a different provider (e.g. just-bash) for unauthenticated/anonymous-visitor demos. See the Deploying to Vercel guide for the full recipe. |
| Upstash | "upstash" | Placeholder — blocked on upstream API stabilization. |
| just-bash | "just-bash" | In-memory bash emulation. No real processes. |
| MOAT | "moat" | Local container isolation with credential injection (requires the moat CLI v0.4.0+). |
| Custom | "custom" | Any object implementing the Sandbox interface. |
MOAT
Runs each command inside a MOAT-managed container on the same host as the agent. The host workspace is bind-mounted in; outbound network calls flow through a credential-injecting proxy so the agent process never sees API tokens.
Install MOAT (one-time, host operator):
The moat CLI is a separate binary — the framework spawns it but does not bundle or auto-install it. Install it from majorcontext.com/moat and verify the version is at least 0.4.0 (required for moat exec):
moat version --jsonPrerequisites the host needs:
- macOS 15+ on Apple Silicon (native containers) or any Linux host with Docker installed.
- One
moat grant <provider>per credential the agent should be able to reach (moat grant github,moat grant openai, etc.). The framework only declares which grant names a workspace requires — it never stores the credentials itself. See the credentials concept page.
Use:
import { createBashCapability } from "@flow-state-dev/tools/bash";
const bashCap = createBashCapability({
provider: {
type: "moat",
grants: ["github"],
allowHosts: ["api.github.com"],
},
});Wiring cleanup is required for the MOAT provider — without it, every flow request leaves a container behind. The capability returns a cleanupBlock for this:
defineFlow({
// ...
request: { onFinished: bashCap.cleanupBlock },
});The cleanup block is returned for every provider so the capability shape stays stable; for non-MOAT providers it is effectively a no-op.
Persistent containers for local dev. MOAT cold-start takes a few seconds. For local development, set a stable runName and persist: true to reuse one container across requests — the cleanup block becomes a no-op, the next request reconnects via moat list --json, and operators reclaim resources with moat stop <runName> or moat clean:
createBashCapability({
provider: {
type: "moat",
runName: "fsdev-dev",
persist: true,
grants: ["github"],
allowHosts: ["api.github.com"],
},
});See the bash docs page for grants, network policy, and limits.
Configuration
createBashTool({
collections: { files: ctx.resources.files },
provider: { type: "vercel" },
destination: "/workspace", // workspace root (default: "/workspace")
persist: true, // persist sandbox across sessions
onBeforeCommand: (cmd) => {
if (cmd.includes("rm -rf /")) return "echo 'Nice try.'";
},
});Where the workspace lives
The local provider creates a workspace directory per scope, at
.fsdev/workspaces/<scope>/<id>/. run and session carry the tenant as well
(.fsdev/workspaces/session/<tenant>/<id>/), because their ids reach the tool
from the request and two tenants can name the same one. user and org don't:
those scopes are shared across tenants by design.
| scope | One workspace per | Reach for it when |
| --- | --- | --- |
| "run" | request | Several agents work at once and must not see each other's half-finished files. |
| "session" (default) | session | A conversation's runs should build on each other. |
| "user" | user | Work should carry across a user's sessions. |
| "org" | org | Work is shared across everyone in an org. |
createBashBlocks({ provider: { type: "local", scope: "run" } });The scope you pick is also what the model is told. bashCommand's description
derives its statement of the workspace's reach from scope, so a run-scoped
workspace is described as belonging to this request and not carrying over —
otherwise an agent leaves work in the workspace expecting a later request to
find it, and that request gets a different directory.
scope and cwd are alternatives. cwd names one directory, so a scope beside
it separates nothing; setting both throws at construction.
The list is ordered narrowest first, and that ordering is the decision.
Everything below "run" is a workspace two runs can be inside at the same
time. That's usually what you want — runs building on each other is the point
of a session — but it's also the only way one run sees another's partial work.
"user" falls back to the session when the context carries no user identity,
so anonymous callers get their own workspace rather than sharing one. An
"org" workspace always has an organization to key on.
Sync lifecycle
- Hydrate — each collection's entries are written into the sandbox under the collection's pattern prefix, so a collection matching
artifacts/**appears at<workspace>/artifacts/ - Execute —
bash,readFile,writeFiletools are available to the LLM - Flush — after every
bashandwriteFile, changed files sync back to their owning collection.readFiledoes not trigger a flush.
A file the run deletes is removed from its collection, but only if the collection still holds what the run was given. If something else changed that file while the run held it, nothing is written or deleted and a warning names the contested path — the run's copy and the collection's copy are both left alone. The same applies to a write, and to a file another run is writing at that moment.
The unit is the collection entry rather than the path, so two sessions each writing their own artifacts/report.md never stand off — those are two files that share a name.
writeFile reports a refusal in its result, not only in the log:
{ success: false, refused: '"artifacts/report.md" is being written by another run — the write was NOT applied.' }The file is in the workspace either way; the workspace is the run's own. success reports whether it reached its collection, so a model told false can retry rather than move on believing the artifact was saved. refused is null whenever it landed.
Files written outside every mounted collection's directory, and outside the scratch directory ./tmp/, are dropped rather than filed somewhere arbitrary. A flush walks the mounts and the workspace root, so a stray file beside the mounts is named in a warning; one written into a subdirectory nothing is mounted at is dropped silently, because walking every directory under the root after each command is not a cost the flush takes.
Workspace path restrictions (Local FS)
The local adapter validates commands and file paths against the workspace root before execution. This is enabled by default (strictPaths: true). It is a best-effort defense for cooperative agents, not a security boundary.
What the guard checks. Before tokenizing, the raw command is screened for shell constructs whose presence is itself the violation: home references (~/), $HOME, command substitution ($(), backticks), process substitution (<(...), >(...)), and path traversals (../). The command is then split into tokens (the same way bash splits words) and each path-shaped token is checked: tokens that resolve outside the workspace root and outside the safe-system allowlist (e.g. /dev/null) are rejected. readFile and writeFile resolve their argument against the workspace root and reject anything that escapes it.
What the guard does not check. Content inside quoted strings, heredoc bodies, and similar opaque arguments is treated as data, not as candidate paths. python3 -c "x = 1 / 2" is allowed because the inner script is a quoted argument. cat << EOF\n/etc/passwd\nEOF is allowed because the body of a heredoc is data, not a filesystem reference. The trade-off is deliberate: scanning quoted content produced unacceptable false positives in inline-code use cases, and one consequence is that a literal absolute path inside either single or double quotes (cat "/etc/passwd", cat '/etc/passwd') is no longer rejected — the unquoted form (cat /etc/passwd) still is.
// Default: strictPaths is true
provider: { type: "local", cwd: "./workspace" }
// Allowed (inline code with arithmetic):
// python3 -c "x = 1 / 2"
// Rejected (unquoted absolute path outside workspace):
// cat /etc/passwd
// Opt out for power users (logs a warning):
provider: { type: "local", cwd: "./workspace", strictPaths: false }When a command is rejected, the error message names the specific offending token so the agent can self-correct. For true isolation, use the just-bash adapter (in-memory emulation) or wait for OS-level sandboxing in a future release.
Direct adapter constructors
import {
createLocalFsSandbox,
createVercelAdapter,
createJustBashSandbox,
} from "@flow-state-dev/tools/bash";MCP (Model Context Protocol)
Connect external MCP servers and expose their tools to generators as framework handler blocks, with selection guidance, tool-description enrichment, and a request-state filter.
import { createMcpCapability } from "@flow-state-dev/tools/mcp";
const mcpCap = createMcpCapability({
servers: [
{
name: "linear",
description: "Project management: issues, projects, cycles, teams.",
whenToUse: "User asks about tasks, tickets, or project work.",
examples: [
"To find open bugs: mcp__linear__list_issues({ filter: { state: 'open' } })",
],
category: "project-management",
transport: {
type: "http",
url: "https://mcp.linear.app/mcp",
headers: { Authorization: `Bearer ${process.env.LINEAR_MCP_API_KEY}` },
},
},
],
});
generator({
uses: [mcpCap],
// ...
});Features
- Namespaced tools. Each MCP tool becomes a handler block named
mcp__<server>__<tool>. - Selection guidance. A markdown system-prompt block is generated from per-server metadata (
description,whenToUse,examples), grouped bycategory. - Description enrichment. Tool descriptions are prefixed with
[server](or[server · category]) so attribution reads as natural language during tool selection. - Request-state filter. The capability contributes a
requestStateSchema. Flows can setctx.request.state.mcp.disabledToolsordisabledServersto narrow tools per turn without reconnecting. - Error isolation. A failed server does not block healthy ones.
Dependency
@ai-sdk/mcp is an optional peer dependency and is loaded dynamically the first time a tool is requested. Apps that don't configure MCP pay no install or bundle cost.
Escape hatch
Use createMcpManager({ servers }) when you need the raw client outside a capability (custom wiring, calling getCatalog() directly, etc.), then pass it to createMcpCapability({ manager }).
Provider-native search
For provider-level search tools (grounded responses, citations), use the search field on generator config instead:
generator({
search: true, // uses the model provider's native search tool
});See @flow-state-dev/core generator docs for details.
