Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Repository files navigation

WebHarness

WebHarness screenshot

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.

Packages

Package npm Description
@webharness/core npm Shared types, SSE protocol, tool interfaces
@webharness/ui npm Drop-in React chat UI
packages/server Reference Hono backend (clone & customize)

Quick start

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 dev

Open http://localhost:5173.


Using the UI package

If you already have a backend that speaks the WebHarness SSE protocol, drop in the React UI:

npm install @webharness/ui @webharness/core
import { 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.


Embedding in your app

@webharness/ui is designed to drop into an existing webapp without forking the server. The host app ships:

  1. The <ChatWindow> component (or composed primitives) somewhere in its React tree
  2. Skills — declarative React components the agent can summon by name
  3. (Optional) imperative client tools for non-UI side effects
  4. (Optional) a custom AuthAdapter so 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.

Minimal setup

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.

Skills — components the agent can summon

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.

Imperative client tools

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.

Authenticated calls

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".

Cross-origin embedding (CORS)

If the UI lives at a different origin than the WebHarness server, set the allowlist:

CORS_ORIGINS=https://app.example.com,https://www.example.com

For cookie- or Authorization-header auth across origins, also set:

CORS_CREDENTIALS=true

Browsers reject credentials: true combined with a wildcard origin, so CORS_ORIGINS=* and CORS_CREDENTIALS=true won't work together — use explicit origins.

Theming

<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.

Multi-user safety

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.


SSE protocol (@webharness/core)

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.


Building a custom backend

Use packages/server as your starting point. The key pieces:

Tool definition

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,
}

Agent loop

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
})

Permission profiles

import { resolvePermission } from "@webharness/core"

// "auto" | "always-ask" | "never" | "read-only" | "web-only"
const decision = resolvePermission(tool, globalProfile, toolOverrides)
// returns "run" | "ask" | "deny"

Chat participants

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")

Hooks

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,
}

Environment variables

# 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 origins

License

MIT

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages