A web-based AI agent harness. Give your users a chat interface backed by a thinking, tool-using Claude agent that streams everything in real time.
| Package | npm | Description |
|---|---|---|
@webharness/core |
Shared types, SSE protocol, tool interfaces | |
@webharness/ui |
Drop-in React chat UI | |
packages/server |
— | Reference Hono backend (clone & customize) |
The fastest way to get running is to clone this repo and use the reference server alongside the UI.
git clone https://github.com/yourname/webharness
cd webharness
pnpm install
cp .env.example .env # add your API keys
pnpm devOpen http://localhost:5173.
If you already have a backend that speaks the WebHarness SSE protocol, drop in the React UI:
npm install @webharness/ui @webharness/coreimport { ChatWindow } from "@webharness/ui"
import "@webharness/ui/styles.css"
export function App() {
return (
<ChatWindow
serverUrl="/api" // your backend's base URL
participantId="default" // optional
/>
)
}ChatWindow manages all state internally. For more control, compose the primitives yourself:
import {
ConversationProvider,
MessageList,
InputBar,
useAgentStream,
} from "@webharness/ui"
function MyChat() {
const { sendMessage, streaming } = useAgentStream({ serverUrl: "/api" })
return (
<ConversationProvider>
<MessageList />
<InputBar onSend={sendMessage} disabled={streaming} />
</ConversationProvider>
)
}For the full embed story — skills, client tools, auth, CORS — see Embedding in your app below.
@webharness/ui is designed to drop into an existing webapp without forking the server. The host app ships:
- The
<ChatWindow>component (or composed primitives) somewhere in its React tree - Skills — declarative React components the agent can summon by name
- (Optional) imperative client tools for non-UI side effects
- (Optional) a custom
AuthAdapterso requests run as authenticated users
The server stays as-is — no fork, no per-app rebuild. Tools and skills travel with each /stream request and are scoped to that session.
import { ChatWindow } from "@webharness/ui"
export function App() {
return <ChatWindow serverUrl="https://your-webharness.example.com/api" />
}That's all you need for a chat-only experience. The server you run needs ANTHROPIC_API_KEY and (for cross-origin) CORS_ORIGINS.
A skill is a React component the agent can invoke as if it were a tool. WebHarness auto-generates the JSON Schema from your Zod props schema, mounts the component when the agent calls the skill, and resolves the tool result with whatever the component passes to its resolve() callback.
import { ChatWindow } from "@webharness/ui"
import { z } from "zod"
function ConfirmSwap({ tokenIn, tokenOut, amount, resolve, reject }) {
return (
<Modal onClose={() => reject("user cancelled")}>
<h3>Swap {amount} {tokenIn} → {tokenOut}?</h3>
<button onClick={() => resolve({ confirmed: true })}>Confirm</button>
<button onClick={() => resolve({ confirmed: false })}>Cancel</button>
</Modal>
)
}
const confirmSwap = {
name: "confirm_swap",
description:
"Show the user a swap confirmation modal. Use after they've agreed to swap. " +
"Returns { confirmed: boolean }.",
propsSchema: z.object({
tokenIn: z.string(),
tokenOut: z.string(),
amount: z.string(),
}),
component: ConfirmSwap,
}
export function App() {
return <ChatWindow serverUrl="/api" skills={[confirmSwap]} />
}The description is what the agent reads to decide when to summon the skill. Treat it like a small prompt — include trigger conditions, the return shape, and edge cases.
<ChatWindow> automatically renders a <SkillRenderer /> inside that mounts whichever skill is currently active. Modal-style — at most one skill at a time. The component receives validated props plus resolve/reject callbacks; calling either ends the invocation and returns the value to the agent.
If you want to expose something that isn't a UI component — read in-page state, dispatch a Redux action, fire an analytics event — use the lower-level registerClientTool plus clientTools prop.
import { ChatWindow, registerClientTool } from "@webharness/ui"
registerClientTool({
name: "get_wallet_balance",
async execute({ address }, { signal }) {
return await wallet.balance(address, { signal })
},
})
export function App() {
return (
<ChatWindow
serverUrl="/api"
clientTools={[
{
name: "get_wallet_balance",
description: "Fetches the connected wallet's balance.",
inputSchema: {
type: "object",
properties: { address: { type: "string" } },
required: ["address"],
},
},
]}
/>
)
}Skills are sugar over this — under the hood, every skill registers a client tool whose handler mounts a component.
The server accepts a pluggable AuthAdapter. The default treats every caller as { userId: "anonymous" }. To wire your own auth, pass an adapter when creating the router:
// packages/server/src/index.ts (or wherever you mount the router)
import type { AuthAdapter } from "@webharness/core"
import { verifyJWT } from "./your-auth.js"
const auth: AuthAdapter = {
async authenticate(req) {
const token = req.headers.get("authorization")?.replace("Bearer ", "")
if (!token) throw new Error("Missing token")
const claims = await verifyJWT(token)
return { userId: claims.sub, tenantId: claims.tenant, claims }
},
}
app.route("/api", createRouter(registry, participants, hooks, { auth }))authenticate() runs on every endpoint (/stream, /permission, /tool-result). Throwing returns 401. The resolved Identity is threaded into ToolContext, so server-side tools can gate behavior on ctx.identity.userId / ctx.identity.tenantId.
On the browser side, attach a bearer token (or anything else) via the headers prop:
<ChatWindow
serverUrl="/api"
headers={() => ({ Authorization: `Bearer ${getToken()}` })}
/>headers accepts a static HeadersInit or a function (sync or async) that returns one. The function form is called before every request, so it's the right shape for tokens that rotate. Content-Type: application/json is always forced on top of whatever you pass — you can't break the JSON parser by accident.
Cookie-based auth works automatically on same-origin deployments. For cookies cross-origin, set CORS_CREDENTIALS=true on the server (see below) and ensure your fetch wrapper sends credentials: "include".
If the UI lives at a different origin than the WebHarness server, set the allowlist:
CORS_ORIGINS=https://app.example.com,https://www.example.comFor cookie- or Authorization-header auth across origins, also set:
CORS_CREDENTIALS=trueBrowsers reject credentials: true combined with a wildcard origin, so CORS_ORIGINS=* and CORS_CREDENTIALS=true won't work together — use explicit origins.
<ChatWindow> ships a default dark theme via CSS custom properties. Two ways to re-skin it:
1. Override the --wh-* tokens in your stylesheet. Best for site-wide branding:
/* anywhere in your app — :root, body, .my-container, etc. */
.my-container {
--wh-bg: #ffffff;
--wh-surface: #f7f7f8;
--wh-surface-2: #ececef;
--wh-border: #e1e1e3;
--wh-text: #1a1a1a;
--wh-text-dim: #6b6b6f;
--wh-accent: #0070f3;
--wh-accent-dim:#0050b3;
--wh-radius: 12px;
}2. Pass a theme prop. Best for per-instance / programmatic overrides:
<ChatWindow
serverUrl="/api"
theme={{
accent: "#0070f3",
radius: "12px",
}}
/>The full token list:
| Prop key | CSS variable | Default | Use |
|---|---|---|---|
bg |
--wh-bg |
#0f0f0f |
Input bar background |
surface |
--wh-surface |
#1a1a1a |
Assistant bubble, tool-call header |
surface2 |
--wh-surface-2 |
#242424 |
Code blocks, hover states |
border |
--wh-border |
#2e2e2e |
All borders |
text |
--wh-text |
#e8e8e8 |
Primary text |
textDim |
--wh-text-dim |
#888 |
Secondary text |
accent |
--wh-accent |
#7c6af7 |
Send button, focus ring |
accentDim |
--wh-accent-dim |
#4a3fa0 |
User bubble, send button hover |
colorSuccess |
--wh-color-success |
#4caf50 |
Allow button, success states |
colorDanger |
--wh-color-danger |
#ef5350 |
Deny hover, error states |
colorWarning |
--wh-color-warning |
#ffd54f |
Pending tool border |
radius |
--wh-radius |
8px |
Bubble / card radius |
fontMono |
--wh-font-mono |
Fira Code… |
Code & thinking text |
Partial overrides are fine — unset keys fall back to the defaults from styles.css.
Each browser tab gets a sessionId (auto-generated, stored in sessionStorage). All /permission and /tool-result POSTs must include the originating sessionId — cross-session resolution returns 404. One server can serve many concurrent users without leaking state between conversations.
The sessionId is a routing primitive, not a security primitive — it's the AuthAdapter plus TLS that establishes trust. For a hosted multi-tenant deployment, pair both.
The UI communicates with the server over a single POST /stream endpoint that returns text/event-stream. The wire format is defined in @webharness/core:
import type { SSEEvent } from "@webharness/core"
type SSEEvent =
| { type: "thinking_start"; turnId: string }
| { type: "thinking_delta"; turnId: string; text: string }
| { type: "thinking_end"; turnId: string }
| { type: "text_delta"; turnId: string; text: string }
| { type: "tool_prepare"; turnId: string; toolUseId: string; toolName: string; message: string }
| { type: "tool_start"; turnId: string; toolUseId: string; toolName: string; input: unknown }
| { type: "tool_result"; turnId: string; toolUseId: string; result: unknown; isError: boolean; durationMs: number }
| { type: "permission_request"; turnId: string; toolUseId: string; toolName: string; message: string }
| { type: "permission_granted"; turnId: string; toolUseId: string }
| { type: "permission_denied"; turnId: string; toolUseId: string }
| { type: "turn_end"; stopReason: "end_turn" | "max_iterations" }
| { type: "error"; code: string; message: string }
| { type: "ping" }Any backend that emits these events will work with @webharness/ui.
Use packages/server as your starting point. The key pieces:
import type { ToolDefinition } from "@webharness/core"
import { z } from "zod"
const myTool: ToolDefinition = {
name: "my_tool",
description: "Does something useful",
inputSchema: z.object({ query: z.string() }),
// Optional: show a message in the UI before executing
prepareInvocation: async ({ query }) => ({
invocationMessage: `Running my tool for "${query}"…`,
pastTenseMessage: `Ran my tool for "${query}"`,
}),
execute: async ({ query }, ctx) => {
// ctx.emit — send SSE events mid-execution
// ctx.signal — AbortSignal, respect it for long-running ops
return { result: "..." }
},
permissionProfile: "auto", // or "always-ask" / "never"
isDestructive: () => false,
}import { runAgentLoop } from "./agent/loop.js"
await runAgentLoop({
identity, // resolved by your AuthAdapter; { userId: "anonymous" } by default
messages,
userMessage,
participant,
registry,
participants,
hooks,
emit,
signal,
requestPermission,
requestClientTool, // resolves browser-side tool invocations
sessionTools, // optional — client tools the host app declared at /stream time
})import { resolvePermission } from "@webharness/core"
// "auto" | "always-ask" | "never" | "read-only" | "web-only"
const decision = resolvePermission(tool, globalProfile, toolOverrides)
// returns "run" | "ask" | "deny"const participants = createParticipantRegistry()
.register({
id: "web",
description: "Search and browse the web",
tools: ["web_search", "fetch_page"],
systemPrompt: "You are a web research assistant.",
matchPrefix: "@web", // users type @web to route here
})
.setDefault("default")const hooks: AgentHooks = {
// Inject context into every turn's system prompt
sessionStart: async ({ messages }) => {
return `Today is ${new Date().toISOString()}.`
},
// Return true to keep the agent running after it wants to stop
stop: async (ctx) => false,
}# Required
ANTHROPIC_API_KEY=sk-ant-...
TAVILY_API_KEY=tvly-... # only if you use the built-in web_search tool
# Server
PORT=3001
MODEL=claude-sonnet-4-6
THINKING_BUDGET=8000
MAX_ITERATIONS=10
PERMISSION_PROFILE=auto # auto | always-ask | never | read-only | web-only
# Client-tool plumbing
CLIENT_TOOL_TIMEOUT_MS=300000 # how long the server waits for a browser-side tool result (5 min default)
MAX_TOOL_RESULT_BYTES=1048576 # 1 MiB cap on serialized tool result size
# CORS — set when embedding from a different origin
CORS_ORIGINS=* # or comma-separated allowlist: https://a.example.com,https://b.example.com
CORS_CREDENTIALS=false # set to true to allow cookies / Authorization across originsMIT
