Getting started
Tools for agents
Fifteen minutes from nothing to an agent calling a capability you own. You end with a project running on your laptop, one tool in it, and Claude, Cursor or Copilot connected to it.
1. Scaffold a project
You need Bun 1.1 or later; the craft CLI runs on it. Installation has the other package managers and the manual route.
bunx create-routecraft my-tools
cd my-tools
The project has one capability folder, capabilities/hello-world, with two routes in it: greet, which looks a user up over HTTP and returns a greeting, and hello-world, which calls greet once at start so you can see the project run.
2. Run it
bun run start
The log ends with Hello, Leanne Graham!. That was hello-world calling greet through the in-process door, direct(), with the user id validated against the capability's input schema before any code ran. Stop it with Ctrl-C.
bun run test
The capability's own test stubs the HTTP lookup with mockAdapter(http, ...), runs the same two routes, and asserts the greeting. It is the shape to copy for every capability you write.
3. Connect your client
greet has a second door: mcp(). The project's craft.config.ts serves every capability with that source as an MCP tool over stdio, so a client spawns the project and talks to it on its standard streams. No port, no credential. Run the client from the project folder.
Claude Code, from the project folder:
claude mcp add my-tools -- bunx craft start --log-file craft.log
The log goes to a file because standard output is the protocol. Cursor, Claude Desktop and VS Code take the same command in JSON, with absolute paths because they do not start the server from the project folder: Expose to an agent has each client's file.
4. Call it
Ask the client: greet user 1. It reads the tool's title, description and input schema, which are the .title(), .description() and .input() on the route, decides to call greet with { "userId": 1 }, and gets Hello, Leanne Graham! back. Ask for user "one" and the schema refuses it before your code runs; the client is told which field was wrong.
That is the whole mechanism. The agent never held a credential for the user service and never saw a URL. It pressed a tool you built.
5. Make it yours
Copy capabilities/hello-world, rename it, and change what it does. The one rule: route.ts is the capability's public surface and the only file another capability may import.
// capabilities/find-overdue-invoices/route.ts
import { craft, direct, log } from "@routecraft/routecraft";
import { mcp } from "@routecraft/ai";
import { z } from "zod";
export default craft()
.id("find-overdue-invoices")
.title("Find overdue invoices")
.description("List invoices more than the given number of days overdue")
.input({ body: z.object({ days: z.number().int().min(1) }) })
.from(direct(), mcp())
.transform(({ days }) => ({ days, invoices: [] }))
.to(log());
The schema is the boundary, and it holds whatever the model decides. Ask your agent for invoices more than 30 days overdue and the call goes through. A call with days: 0, or days: "a month", is refused before any step runs, and the refusal names the failing field so the agent can correct itself. The model chooses what to ask for; your code decides which actions exist, what input they accept and under which conditions they run. That does not make the model's output deterministic. It bounds what that output can do.
Who may call is the next boundary. Over stdio on your laptop the caller is you, so there is nothing to check. Once the capability is served over HTTP on the team harness, behind an authenticated door, .authorize() names the roles and scopes a caller needs: Credentials and identity has the model.
craft start discovers the new folder on its own. Give the route an http() source as well and it is also an endpoint, behind the same schema.
A schedule is different: a cron() source starts a run with no body, and this capability's .input() would refuse it. Give the schedule a route of its own that builds the input and calls the capability through its direct() door (cron() needs the optional peer: bun add croner):
// capabilities/daily-overdue-invoices/route.ts
import { craft, cron, direct, log } from "@routecraft/routecraft";
export default craft()
.id("daily-overdue-invoices")
.from(cron("0 8 * * *"))
.transform(() => ({ days: 30 }))
.to(direct("find-overdue-invoices"))
.to(log());
The capability stays written once, and the schedule passes the same input check as an agent's call.
Where to go next
Local harness, team harness
Share it with your team: the same code, run always on, on service credentials no person holds.
Doors and triggers
Reach it the other ways: the CLI, HTTP, and what starts a capability when nobody asks.
Credentials and identity
Decide who may call it: whose credential a capability runs on, and how it names the callers it admits.