Add minimal stock listing app: actions, Postgres schema, stocks page, Claude Agent SDK harness

- Add stocks table (server/db) with list/get/add/update/delete actions, scoped
  per user, backing both the chat agent and the /stocks UI via
  useActionQuery/useActionMutation
- Add mobile-first /stocks page (shadcn table/cards, watchlist, search) and
  wire it into the sidebar, navigation state, and view-screen context
- Install the platform's Claude Agent SDK harness (server/plugins,
  server/routes/api/claude-agent) so the app agent runs on the platform's own
  Claude subscription instead of the Builder.io gateway; fix the vendored
  turn.post.ts route to resolve the session and wrap it in
  runWithRequestContext, since custom /api/ routes aren't auto-wrapped in
  request context the way /_agent-native/actions/* are
- Add shadcn table/badge/dialog/select components (Tabler icons, not lucide)
This commit is contained in:
exe.dev user
2026-07-29 12:18:14 +00:00
parent 138d195e4c
commit e859245867
25 changed files with 14804 additions and 10 deletions
+3
View File
@@ -3,3 +3,6 @@ PING_MESSAGE=pong
# Skip login/signup (local dev / preview only)
# AUTH_DISABLED=true
# Postgres connection (local dev: Docker container "stocklist-postgres")
# DATABASE_URL=postgres://stocklist:<password>@127.0.0.1:5432/stocklist
+28 -1
View File
@@ -43,10 +43,37 @@ workflow needs durable UI around the conversation.
- For new features, update UI, actions, skills/instructions, and application
state when applicable.
## What This App Does
A minimal stock listing app. Each user keeps a personal list of stock tickers
(symbol, company name, exchange, sector, price, change percent, market cap,
watchlisted) at the `/stocks` page. Chat remains the primary surface for
asking the agent to add, look up, or edit listings; `/stocks` is the durable
UI for browsing, searching, and managing the list at a glance.
## Actions
| Action | Method | Description |
| -------------- | ------ | ------------------------------------------------------------------------ |
| `list-stocks` | GET | List the current user's stock listings. Supports `search`, `exchange`, `watchlistedOnly`. |
| `get-stock` | GET | Get one listing by `id` or `symbol`. |
| `add-stock` | POST | Add a new listing (`symbol`, `companyName` required; fails if symbol already listed). |
| `update-stock` | PUT | Patch a listing by `id` — price, change percent, sector, `watchlisted` ("true"/"false"), etc. |
| `delete-stock` | DELETE | Delete a listing by `id`. |
| `hello` | GET | Starter example action. |
| `navigate` | agent-only | Navigate the UI to a view/path (adds `view: "stocks"` for `/stocks`).|
| `view-screen` | agent-only | Read current navigation state; when the user is on `/stocks`, also returns the full stock listing snapshot. |
Stocks are scoped per `ownerEmail` (the authenticated user). Data lives in
the `stocks` table in Postgres (`DATABASE_URL`) in this deployment, or SQLite
at `data/app.db` for local dev without a `DATABASE_URL` — see
`server/db/schema.ts` and `server/plugins/db.ts`.
## Application State
- `navigation` should describe the current view and selected entity ids. The
default chat view is `chat` at `/`.
default chat view is `chat` at `/`; the stock listing view is `stocks` at
`/stocks`.
- `navigate` may be used to move the UI when the app supports it.
- `view-screen` is the first tool to call when the user's visible context
matters.
+64
View File
@@ -0,0 +1,64 @@
import { randomUUID } from "node:crypto";
import { defineAction } from "@agent-native/core/action";
import { and, eq } from "@agent-native/core/db/schema";
import { z } from "zod";
import { getDb } from "../server/db/index.js";
import { stocks } from "../server/db/schema.js";
export default defineAction({
description:
"Add a new stock listing. Fails if the symbol is already listed for this user.",
schema: z.object({
symbol: z.string().min(1).max(10).describe("Ticker symbol, e.g. AAPL"),
companyName: z.string().min(1).describe("Company name"),
exchange: z
.string()
.default("NASDAQ")
.describe("Exchange, e.g. NASDAQ, NYSE"),
sector: z.string().optional().describe("Sector, e.g. Technology"),
currency: z.string().default("USD").describe("Currency code, e.g. USD"),
price: z.coerce.number().default(0).describe("Current price"),
changePercent: z.coerce
.number()
.default(0)
.describe("Price change percent, e.g. 1.5 or -2.3"),
marketCap: z.coerce
.number()
.optional()
.describe("Market capitalization"),
}),
run: async (args, ctx) => {
if (!ctx?.userEmail) throw new Error("Authentication required.");
const db = getDb();
const symbol = args.symbol.toUpperCase();
const existing = await db
.select({ id: stocks.id })
.from(stocks)
.where(and(eq(stocks.ownerEmail, ctx.userEmail), eq(stocks.symbol, symbol)));
if (existing[0]) {
throw new Error(`${symbol} is already listed.`);
}
const now = new Date().toISOString();
const row = {
id: randomUUID(),
symbol,
companyName: args.companyName,
exchange: args.exchange,
sector: args.sector ?? null,
currency: args.currency,
price: args.price,
changePercent: args.changePercent,
marketCap: args.marketCap ?? null,
watchlisted: false,
ownerEmail: ctx.userEmail,
createdAt: now,
updatedAt: now,
};
await db.insert(stocks).values(row);
return row;
},
});
+24
View File
@@ -0,0 +1,24 @@
import { defineAction } from "@agent-native/core/action";
import { and, eq } from "@agent-native/core/db/schema";
import { z } from "zod";
import { getDb } from "../server/db/index.js";
import { stocks } from "../server/db/schema.js";
export default defineAction({
description: "Delete a stock listing by id.",
schema: z.object({
id: z.string().describe("Stock listing id"),
}),
http: { method: "DELETE" },
run: async (args, ctx) => {
if (!ctx?.userEmail) throw new Error("Authentication required.");
const db = getDb();
const deleted = await db
.delete(stocks)
.where(and(eq(stocks.id, args.id), eq(stocks.ownerEmail, ctx.userEmail)))
.returning();
if (!deleted[0]) throw new Error("Stock not found.");
return { deleted: true, id: args.id };
},
});
+37
View File
@@ -0,0 +1,37 @@
import { defineAction } from "@agent-native/core/action";
import { and, eq } from "@agent-native/core/db/schema";
import { z } from "zod";
import { getDb } from "../server/db/index.js";
import { stocks } from "../server/db/schema.js";
export default defineAction({
description: "Get a single stock listing by id or symbol.",
schema: z.object({
id: z.string().optional().describe("Stock listing id"),
symbol: z.string().optional().describe("Stock ticker symbol"),
}),
http: { method: "GET" },
readOnly: true,
run: async (args, ctx) => {
if (!ctx?.userEmail) throw new Error("Authentication required.");
if (!args.id && !args.symbol) {
throw new Error("Either id or symbol is required.");
}
const db = getDb();
const rows = await db
.select()
.from(stocks)
.where(
and(
eq(stocks.ownerEmail, ctx.userEmail),
args.id
? eq(stocks.id, args.id)
: eq(stocks.symbol, args.symbol!.toUpperCase()),
),
);
const stock = rows[0];
if (!stock) throw new Error("Stock not found.");
return stock;
},
});
+56
View File
@@ -0,0 +1,56 @@
import { defineAction } from "@agent-native/core/action";
import { and, desc, eq, or, sql } from "@agent-native/core/db/schema";
import { z } from "zod";
import { getDb } from "../server/db/index.js";
import { stocks } from "../server/db/schema.js";
function booleanParam(value: string | undefined): boolean | undefined {
if (value === undefined) return undefined;
return value === "true" || value === "1";
}
export default defineAction({
description:
"List stock listings, optionally filtered by search text (symbol or company name), exchange, or watchlisted status.",
schema: z.object({
search: z
.string()
.optional()
.describe("Filter by symbol or company name (case-insensitive substring match)"),
exchange: z.string().optional().describe("Filter by exchange (e.g. NASDAQ, NYSE)"),
watchlistedOnly: z
.string()
.optional()
.describe("Set to \"true\" to only return watchlisted stocks"),
}),
http: { method: "GET" },
readOnly: true,
run: async (args, ctx) => {
if (!ctx?.userEmail) throw new Error("Authentication required.");
const db = getDb();
const conditions = [eq(stocks.ownerEmail, ctx.userEmail)];
if (args.search) {
const term = `%${args.search.toLowerCase()}%`;
conditions.push(
or(
sql`lower(${stocks.symbol}) like ${term}`,
sql`lower(${stocks.companyName}) like ${term}`,
)!,
);
}
if (args.exchange) conditions.push(eq(stocks.exchange, args.exchange));
if (booleanParam(args.watchlistedOnly)) {
conditions.push(eq(stocks.watchlisted, true));
}
const rows = await db
.select()
.from(stocks)
.where(and(...conditions))
.orderBy(desc(stocks.watchlisted), stocks.symbol);
return { stocks: rows };
},
});
+47
View File
@@ -0,0 +1,47 @@
import { defineAction } from "@agent-native/core/action";
import { and, eq } from "@agent-native/core/db/schema";
import { z } from "zod";
import { getDb } from "../server/db/index.js";
import { stocks } from "../server/db/schema.js";
export default defineAction({
description:
"Update fields on an existing stock listing (patch — only pass the fields that change). Use this to edit price, change percent, sector, or toggle the watchlist.",
schema: z.object({
id: z.string().describe("Stock listing id"),
companyName: z.string().optional(),
exchange: z.string().optional(),
sector: z.string().optional(),
currency: z.string().optional(),
price: z.coerce.number().optional(),
changePercent: z.coerce.number().optional(),
marketCap: z.coerce.number().optional(),
watchlisted: z
.enum(["true", "false"])
.optional()
.describe("Set to \"true\" or \"false\" to toggle the watchlist"),
}),
http: { method: "PUT" },
run: async (args, ctx) => {
if (!ctx?.userEmail) throw new Error("Authentication required.");
const { id, watchlisted, ...rest } = args;
const db = getDb();
const patch: Record<string, unknown> = { ...rest };
if (watchlisted !== undefined) patch.watchlisted = watchlisted === "true";
if (Object.keys(patch).length === 0) {
throw new Error("No fields to update.");
}
patch.updatedAt = new Date().toISOString();
const updated = await db
.update(stocks)
.set(patch)
.where(and(eq(stocks.id, id), eq(stocks.ownerEmail, ctx.userEmail)))
.returning();
if (!updated[0]) throw new Error("Stock not found.");
return updated[0];
},
});
+15 -1
View File
@@ -9,20 +9,34 @@
import { defineAction } from "@agent-native/core/action";
import { readAppState } from "@agent-native/core/application-state";
import { eq } from "@agent-native/core/db/schema";
import { z } from "zod";
import { getDb } from "../server/db/index.js";
import { stocks } from "../server/db/schema.js";
export default defineAction({
description:
"See what the user is currently looking at on screen. Returns the current navigation state for the chat-first app. Always call this first before taking any action.",
schema: z.object({}),
http: false,
readOnly: true,
run: async () => {
run: async (_args, ctx) => {
const navigation = await readAppState("navigation");
const screen: Record<string, unknown> = {};
if (navigation) screen.navigation = navigation;
const view = (navigation as { view?: string } | null)?.view;
if (view === "stocks" && ctx?.userEmail) {
const db = getDb();
const rows = await db
.select()
.from(stocks)
.where(eq(stocks.ownerEmail, ctx.userEmail));
screen.stocks = { count: rows.length, listings: rows };
}
if (Object.keys(screen).length === 0) {
return "No application state found. Is the app running?";
}
+7
View File
@@ -14,6 +14,7 @@ import {
type ChatHistoryItem,
} from "@agent-native/toolkit/chat-history";
import {
IconChartLine,
IconLayoutSidebarLeftCollapse,
IconLayoutSidebarLeftExpand,
IconMessageCircle,
@@ -39,6 +40,12 @@ const navItems = [
href: "/",
view: "chat",
},
{
icon: IconChartLine,
labelKey: "navigation.stocks",
href: "/stocks",
view: "stocks",
},
];
const bottomNavItems = [
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+120
View File
@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { IconX } from "@tabler/icons-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<IconX className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}
+160
View File
@@ -0,0 +1,160 @@
"use client"
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { IconCheck, IconChevronDown, IconChevronUp } from "@tabler/icons-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<IconChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<IconChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<IconChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<IconCheck className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}
+117
View File
@@ -0,0 +1,117 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto">
<table
ref={ref}
className={cn("w-full caption-bottom text-sm", className)}
{...props}
/>
</div>
))
Table.displayName = "Table"
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn("[&_tr]:border-b", className)} {...props} />
))
TableHeader.displayName = "TableHeader"
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn("[&_tr:last-child]:border-0", className)}
{...props}
/>
))
TableBody.displayName = "TableBody"
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
"border-t bg-muted/50 font-medium [&>tr]:last:border-b-0",
className
)}
{...props}
/>
))
TableFooter.displayName = "TableFooter"
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
"border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted",
className
)}
{...props}
/>
))
TableRow.displayName = "TableRow"
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
"h-12 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0",
className
)}
{...props}
/>
))
TableHead.displayName = "TableHead"
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn("p-4 align-middle [&:has([role=checkbox])]:pr-0", className)}
{...props}
/>
))
TableCell.displayName = "TableCell"
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn("mt-4 text-sm text-muted-foreground", className)}
{...props}
/>
))
TableCaption.displayName = "TableCaption"
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}
+3
View File
@@ -39,6 +39,7 @@ function threadIdFromPath(pathname: string): string | null {
function viewForPath(pathname: string): string {
if (isChatPath(pathname)) return "chat";
if (pathname.startsWith("/stocks")) return "stocks";
if (pathname.startsWith("/database")) return "database";
if (pathname.startsWith("/extensions")) return "extensions";
if (pathname.startsWith("/observability")) return "observability";
@@ -53,6 +54,8 @@ function pathForView(view?: string): string {
case "home":
case "ask":
return "/";
case "stocks":
return "/stocks";
case "database":
return "/database";
case "extensions":
+2
View File
@@ -50,11 +50,13 @@ const messages = {
observability: "Observability",
openNavigation: "Open navigation",
settings: "Settings",
stocks: "Stocks",
team: "Team",
},
pages: {
databaseTitle: "Database",
observabilityPageTitle: "Agent Observability",
stocksDescription: "Track tickers, prices, and your watchlist.",
teamTitle: "Team",
teamCreateOrgDescription:
"Create an organization to invite teammates and share this app.",
+414
View File
@@ -0,0 +1,414 @@
import {
useActionMutation,
useActionQuery,
} from "@agent-native/core/client/hooks";
import { useT } from "@agent-native/core/client/i18n";
import { useSetPageTitle } from "@agent-native/toolkit/app-shell";
import { IconPlus, IconStar, IconStarFilled, IconTrash } from "@tabler/icons-react";
import { useMemo, useState } from "react";
import { toast } from "sonner";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card";
import {
Dialog,
DialogContent,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/components/ui/table";
import { APP_TITLE } from "@/lib/app-config";
import { cn } from "@/lib/utils";
export function meta() {
return [{ title: `Stocks - ${APP_TITLE}` }];
}
interface StockFormState {
symbol: string;
companyName: string;
exchange: string;
sector: string;
currency: string;
price: string;
changePercent: string;
marketCap: string;
}
const EMPTY_FORM: StockFormState = {
symbol: "",
companyName: "",
exchange: "NASDAQ",
sector: "",
currency: "USD",
price: "",
changePercent: "",
marketCap: "",
};
function formatMoney(value: number, currency: string) {
try {
return new Intl.NumberFormat(undefined, {
style: "currency",
currency,
maximumFractionDigits: 2,
}).format(value);
} catch {
return `${value.toFixed(2)} ${currency}`;
}
}
function formatMarketCap(value: number | null) {
if (value === null || value === undefined) return "—";
if (value >= 1_000_000_000_000) return `${(value / 1_000_000_000_000).toFixed(2)}T`;
if (value >= 1_000_000_000) return `${(value / 1_000_000_000).toFixed(2)}B`;
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M`;
return String(value);
}
function AddStockDialog() {
const [open, setOpen] = useState(false);
const [form, setForm] = useState<StockFormState>(EMPTY_FORM);
const addStock = useActionMutation("add-stock", {
onSuccess: () => {
toast.success(`${form.symbol.toUpperCase()} added`);
setForm(EMPTY_FORM);
setOpen(false);
},
onError: (error) => toast.error(error.message),
});
function submit() {
if (!form.symbol.trim() || !form.companyName.trim()) {
toast.error("Symbol and company name are required.");
return;
}
addStock.mutate({
symbol: form.symbol.trim(),
companyName: form.companyName.trim(),
exchange: form.exchange.trim() || "NASDAQ",
sector: form.sector.trim() || undefined,
currency: form.currency.trim() || "USD",
price: form.price ? Number(form.price) : 0,
changePercent: form.changePercent ? Number(form.changePercent) : 0,
marketCap: form.marketCap ? Number(form.marketCap) : undefined,
});
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button size="sm" className="gap-1.5">
<IconPlus className="size-4" />
Add stock
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Add stock</DialogTitle>
</DialogHeader>
<div className="grid grid-cols-2 gap-3">
<div className="col-span-2 space-y-1.5">
<Label htmlFor="symbol">Symbol</Label>
<Input
id="symbol"
placeholder="AAPL"
value={form.symbol}
onChange={(e) => setForm({ ...form, symbol: e.target.value })}
/>
</div>
<div className="col-span-2 space-y-1.5">
<Label htmlFor="companyName">Company name</Label>
<Input
id="companyName"
placeholder="Apple Inc."
value={form.companyName}
onChange={(e) => setForm({ ...form, companyName: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="exchange">Exchange</Label>
<Input
id="exchange"
value={form.exchange}
onChange={(e) => setForm({ ...form, exchange: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="sector">Sector</Label>
<Input
id="sector"
placeholder="Technology"
value={form.sector}
onChange={(e) => setForm({ ...form, sector: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="price">Price</Label>
<Input
id="price"
type="number"
inputMode="decimal"
value={form.price}
onChange={(e) => setForm({ ...form, price: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="changePercent">Change %</Label>
<Input
id="changePercent"
type="number"
inputMode="decimal"
value={form.changePercent}
onChange={(e) =>
setForm({ ...form, changePercent: e.target.value })
}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="currency">Currency</Label>
<Input
id="currency"
value={form.currency}
onChange={(e) => setForm({ ...form, currency: e.target.value })}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="marketCap">Market cap</Label>
<Input
id="marketCap"
type="number"
inputMode="decimal"
value={form.marketCap}
onChange={(e) => setForm({ ...form, marketCap: e.target.value })}
/>
</div>
</div>
<DialogFooter>
<Button onClick={submit} disabled={addStock.isPending}>
{addStock.isPending ? "Adding…" : "Add stock"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
interface StockRow {
id: string;
symbol: string;
companyName: string;
exchange: string;
sector: string | null;
currency: string;
price: number;
changePercent: number;
marketCap: number | null;
watchlisted: boolean;
}
function ChangeBadge({ value }: { value: number }) {
const positive = value > 0;
const negative = value < 0;
return (
<Badge
variant="outline"
className={cn(
"font-mono tabular-nums",
positive && "border-emerald-500/40 text-emerald-600 dark:text-emerald-400",
negative && "border-red-500/40 text-red-600 dark:text-red-400",
)}
>
{positive ? "+" : ""}
{value.toFixed(2)}%
</Badge>
);
}
function WatchlistButton({ stock }: { stock: StockRow }) {
const toggle = useActionMutation("update-stock", {
method: "PUT",
onError: (error) => toast.error(error.message),
});
return (
<Button
variant="ghost"
size="icon"
className="size-8"
aria-label={stock.watchlisted ? "Remove from watchlist" : "Add to watchlist"}
disabled={toggle.isPending}
onClick={() =>
toggle.mutate({
id: stock.id,
watchlisted: stock.watchlisted ? "false" : "true",
})
}
>
{stock.watchlisted ? (
<IconStarFilled className="size-4 text-amber-500" />
) : (
<IconStar className="size-4 text-muted-foreground" />
)}
</Button>
);
}
function DeleteStockButton({ stock }: { stock: StockRow }) {
const del = useActionMutation("delete-stock", {
method: "DELETE",
onSuccess: () => toast.success(`${stock.symbol} removed`),
onError: (error) => toast.error(error.message),
});
return (
<Button
variant="ghost"
size="icon"
className="size-8 text-muted-foreground hover:text-destructive"
aria-label={`Delete ${stock.symbol}`}
disabled={del.isPending}
onClick={() => {
if (window.confirm(`Remove ${stock.symbol} from your listings?`)) {
del.mutate({ id: stock.id });
}
}}
>
<IconTrash className="size-4" />
</Button>
);
}
export default function StocksRoute() {
const t = useT();
useSetPageTitle(t("navigation.stocks"));
const [search, setSearch] = useState("");
const { data, isLoading } = useActionQuery("list-stocks", {
search: search || undefined,
});
const stocks = useMemo(() => (data?.stocks ?? []) as StockRow[], [data]);
return (
<div className="mx-auto flex w-full max-w-4xl flex-col gap-4 p-4 sm:p-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight">
{t("navigation.stocks")}
</h1>
<p className="text-sm text-muted-foreground">
{t("pages.stocksDescription")}
</p>
</div>
<AddStockDialog />
</div>
<Input
placeholder="Search symbol or company…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="sm:max-w-xs"
/>
{isLoading ? (
<p className="py-8 text-center text-sm text-muted-foreground">Loading</p>
) : stocks.length === 0 ? (
<Card>
<CardContent className="py-10 text-center text-sm text-muted-foreground">
No stocks listed yet. Add one, or ask the agent to add some.
</CardContent>
</Card>
) : (
<>
{/* Mobile: stacked cards */}
<div className="flex flex-col gap-2 sm:hidden">
{stocks.map((stock) => (
<Card key={stock.id}>
<CardContent className="flex items-center gap-3 py-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="font-semibold">{stock.symbol}</span>
<span className="truncate text-xs text-muted-foreground">
{stock.exchange}
</span>
</div>
<p className="truncate text-sm text-muted-foreground">
{stock.companyName}
</p>
<div className="mt-1 flex items-center gap-2">
<span className="font-mono text-sm tabular-nums">
{formatMoney(stock.price, stock.currency)}
</span>
<ChangeBadge value={stock.changePercent} />
</div>
</div>
<WatchlistButton stock={stock} />
<DeleteStockButton stock={stock} />
</CardContent>
</Card>
))}
</div>
{/* Desktop: table */}
<div className="hidden overflow-x-auto rounded-md border sm:block">
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead>Symbol</TableHead>
<TableHead>Company</TableHead>
<TableHead>Exchange</TableHead>
<TableHead>Sector</TableHead>
<TableHead className="text-right">Price</TableHead>
<TableHead className="text-right">Change</TableHead>
<TableHead className="text-right">Market Cap</TableHead>
<TableHead className="w-8" />
</TableRow>
</TableHeader>
<TableBody>
{stocks.map((stock) => (
<TableRow key={stock.id}>
<TableCell>
<WatchlistButton stock={stock} />
</TableCell>
<TableCell className="font-semibold">{stock.symbol}</TableCell>
<TableCell className="max-w-52 truncate text-muted-foreground">
{stock.companyName}
</TableCell>
<TableCell className="text-muted-foreground">
{stock.exchange}
</TableCell>
<TableCell className="text-muted-foreground">
{stock.sector ?? "—"}
</TableCell>
<TableCell className="text-right font-mono tabular-nums">
{formatMoney(stock.price, stock.currency)}
</TableCell>
<TableCell className="text-right">
<ChangeBadge value={stock.changePercent} />
</TableCell>
<TableCell className="text-right text-muted-foreground">
{formatMarketCap(stock.marketCap)}
</TableCell>
<TableCell>
<DeleteStockButton stock={stock} />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</>
)}
</div>
);
}
+5 -4
View File
@@ -16,18 +16,19 @@
"dependencies": {
"@agent-native/core": "0.129.1",
"@agent-native/toolkit": "^0.10.11",
"@anthropic-ai/claude-agent-sdk": "^0.3.220",
"@fontsource-variable/inter": "^5.2.8",
"@libsql/client": "^0.15.8",
"@react-router/dev": "8.1.0",
"@react-router/fs-routes": "8.1.0",
"@tabler/icons-react": "^3.41.1",
"h3": "^2.0.1-rc.20",
"isbot": "^5",
"node-pty": "^1.1.0",
"zod": "^4.4.3",
"postgres": "^3.4.9",
"@react-router/dev": "8.1.0",
"@react-router/fs-routes": "8.1.0",
"react-router": "8.1.0",
"vite": "8.1.0"
"vite": "8.1.0",
"zod": "^4.4.3"
},
"devDependencies": {
"@radix-ui/react-accordion": "^1.2.12",
+12601
View File
File diff suppressed because it is too large Load Diff
+3 -1
View File
@@ -1,8 +1,8 @@
allowBuilds:
node-pty: true
esbuild: true
better-sqlite3: true
tesseract.js: false
overrides:
nf3: "0.3.17"
@@ -19,3 +19,5 @@ minimumReleaseAgeExclude:
- "@modelcontextprotocol/node"
- "@modelcontextprotocol/core"
- "@modelcontextprotocol/client"
- '@agent-native/core@0.129.1'
- '@agent-native/toolkit@0.10.11'
+5
View File
@@ -0,0 +1,5 @@
import { createGetDb } from "@agent-native/core/db";
import * as schema from "./schema.js";
export const getDb = createGetDb(schema);
+22
View File
@@ -0,0 +1,22 @@
import { integer, now, real, table, text } from "@agent-native/core/db/schema";
export const stocks = table("stocks", {
id: text("id").primaryKey(),
symbol: text("symbol").notNull(),
companyName: text("company_name").notNull(),
exchange: text("exchange").notNull().default("NASDAQ"),
sector: text("sector"),
currency: text("currency").notNull().default("USD"),
price: real("price").notNull().default(0),
changePercent: real("change_percent").notNull().default(0),
marketCap: integer("market_cap"),
watchlisted: integer("watchlisted", { mode: "boolean" })
.notNull()
.default(false),
ownerEmail: text("owner_email").notNull(),
createdAt: text("created_at").notNull().default(now()),
updatedAt: text("updated_at").notNull().default(now()),
});
export type Stock = typeof stocks.$inferSelect;
export type NewStock = typeof stocks.$inferInsert;
+12 -3
View File
@@ -6,16 +6,25 @@ import {
import actionsRegistry from "../../.generated/actions-registry.js";
const INITIAL_TOOL_NAMES = ["view-screen", "navigate", "hello"];
const INITIAL_TOOL_NAMES = [
"view-screen",
"navigate",
"hello",
"list-stocks",
"get-stock",
"add-stock",
"update-stock",
"delete-stock",
];
export default createAgentChatPlugin({
appId: "app",
actions: loadActionsFromStaticRegistry(actionsRegistry),
initialToolNames: INITIAL_TOOL_NAMES,
resolveOrgId: async (event) => (await getOrgContext(event)).orgId,
systemPrompt: `You are the Chat app agent.
systemPrompt: `You are the Chat app agent for a minimal stock listing app.
This is a minimal chat-first Agent-Native app. The chat is the product surface, and actions are the contract shared by chat, UI, HTTP, MCP, A2A, and CLI.
The chat is the product surface, and actions are the contract shared by chat, UI, HTTP, MCP, A2A, and CLI. The app tracks a per-user list of stock tickers (symbol, company name, exchange, sector, price, change percent, market cap, watchlisted) at the /stocks page, backed by the list-stocks, get-stock, add-stock, update-stock, and delete-stock actions.
Use actions as the source of truth. Start by inspecting the current screen when context matters. When the user asks to extend this app, keep the change small and agent-native: add or update actions, expose useful UI, and keep application state/navigation visible to the agent.`,
});
+847
View File
@@ -0,0 +1,847 @@
// GENERATED — do not edit here.
//
// Source: https://github.com/sharpshooter90/agent-native.git
// branch privatecloud/claude-agent-sdk-harness @ db5f84e9c596549d73164cb1c5f4aae009af1465
// packages/core/src/agent/harness/claude-agent-sdk-adapter.ts
// Regenerate: node scripts/vendor-claude-harness.mjs <path-to-agent-native>
//
// Why this file exists: apps install @agent-native/core from npm, which does not
// carry this harness yet. Registering it here through the framework's public
// registry gives the app the same capability without forking the installed
// package. Delete this file once a published core ships `claude-agent-sdk`.
/**
* Claude Agent SDK harness adapter.
*
* Runs Claude Code through `@anthropic-ai/claude-agent-sdk` as an embedded
* harness: the SDK owns its loop, native file/shell tools, compaction, and
* session state, while Agent-Native drives it as a session and streams its
* events into the normal transcript.
*
* Two things distinguish this from `acp:claude-code`, which reaches the same
* runtime through the Zed ACP bridge:
*
* - The SDK is the first-party surface, so session resume is the SDK's own
* `session_id` (not an ACP `loadSession` capability that may be absent),
* and host tools are bridged as an in-process MCP server rather than being
* unavailable.
* - Auth is whatever the spawned `claude` binary already has. The child
* inherits the host environment, so a host that runs on a Claude
* subscription token (`CLAUDE_CODE_OAUTH_TOKEN`) — rather than a metered
* `ANTHROPIC_API_KEY` — can reuse it here. This adapter never reads those
* variables itself; a host that needs to inject credentials passes them
* through `env`, which is layered onto the inherited environment.
*
* The SDK package is an optional dependency loaded lazily through
* `installPackage`, so apps that never use this harness do not pay for it.
* Everything beyond the thin query/session glue is pure mapping logic between
* SDK messages and {@link AgentHarnessEvent}s.
*/
import { randomUUID } from "node:crypto";
import {
registerAgentHarness,
type AgentHarnessAdapter,
type AgentHarnessApproval,
type AgentHarnessCapabilities,
type AgentHarnessCreateSessionOptions,
type AgentHarnessEvent,
type AgentHarnessMessage,
type AgentHarnessPermissionMode,
type AgentHarnessSession,
type AgentHarnessTurnInput,
} from "@agent-native/core/agent/harness";
export const CLAUDE_AGENT_SDK_PACKAGE = "@anthropic-ai/claude-agent-sdk";
/** MCP server name the host tools from `createSession.tools` are mounted under. */
export const HOST_TOOL_MCP_SERVER = "agent-native";
/** Claude Code tools that only observe; safe under every permission mode. */
const READ_ONLY_TOOLS = new Set([
"Glob",
"Grep",
"NotebookRead",
"Read",
"Task",
"TodoWrite",
"WebFetch",
"WebSearch",
]);
/** Claude Code tools that mutate workspace files; gated by `allow-edits`. */
const EDIT_TOOLS = new Set(["Edit", "MultiEdit", "NotebookEdit", "Write"]);
/** Input keys the edit tools use to name their target file. */
const FILE_PATH_KEYS = ["file_path", "notebook_path", "path"] as const;
export interface ClaudeAgentSdkHarnessAdapterOptions {
label?: string;
description?: string;
/** Model id passed to the SDK. Omit to use the CLI's configured default. */
model?: string;
/** Default workspace directory when a session does not supply `cwd`. */
cwd?: string;
permissionMode?: AgentHarnessPermissionMode;
/**
* Extra environment for the spawned `claude` process, layered onto the
* inherited host environment. Use this to hand the child host-resolved
* credentials instead of reading them in this module.
*/
env?: Record<string, string>;
/** Path to the `claude` executable when it is not the packaged one. */
pathToClaudeCodeExecutable?: string;
/**
* Which on-disk settings the SDK loads (CLAUDE.md, slash commands, MCP
* config). The SDK default is none; pass `["project"]` to let an app's own
* repo settings shape the agent.
*/
settingSources?: Array<"user" | "project" | "local">;
/** Extra `query()` options, merged last except where noted below. */
queryOptions?: Record<string, unknown>;
/**
* @internal Test seam: supply the SDK module instead of importing it.
*/
loadClaudeAgentSdk?: () => Promise<unknown>;
}
/** Options this adapter owns; a host overriding them would break the contract. */
const RESERVED_QUERY_OPTIONS = [
"abortController",
"canUseTool",
"includePartialMessages",
"resume",
] as const;
export interface ClaudeAgentSdkResumeState {
sessionId: string;
cwd?: string;
}
/** Per-turn correlation between tool_use ids and the call they started. */
export interface ClaudeAgentSdkTurnState {
toolCalls: Map<string, { name: string; input: unknown }>;
}
export function createClaudeAgentSdkTurnState(): ClaudeAgentSdkTurnState {
return { toolCalls: new Map() };
}
const dynamicImport = new Function("specifier", "return import(specifier)") as (
specifier: string,
) => Promise<any>;
export function createClaudeAgentSdkHarnessAdapter(
options: ClaudeAgentSdkHarnessAdapterOptions = {},
): AgentHarnessAdapter {
assertNoReservedQueryOptions(options.queryOptions);
const capabilities: AgentHarnessCapabilities = {
// The SDK runs the agent in a real working directory with its own file and
// shell tools; it is not sandboxed by Agent-Native.
sandbox: false,
resumable: true,
approvals: true,
hostTools: true,
fileEvents: true,
};
return {
name: "claude-agent-sdk",
label: options.label ?? "Claude Code (Agent SDK)",
description:
options.description ??
"Runs Claude Code through the first-party Claude Agent SDK, reusing the host's existing Claude credentials.",
installPackage: CLAUDE_AGENT_SDK_PACKAGE,
capabilities,
async createSession(sessionOptions) {
const sdk = (await (options.loadClaudeAgentSdk
? options.loadClaudeAgentSdk()
: dynamicImport(CLAUDE_AGENT_SDK_PACKAGE))) as any;
if (typeof sdk?.query !== "function") {
throw new Error(
`[agent-harness] ${CLAUDE_AGENT_SDK_PACKAGE} did not expose query().`,
);
}
return new ClaudeAgentSdkHarnessSession(sdk, options, sessionOptions);
},
};
}
function assertNoReservedQueryOptions(
queryOptions: Record<string, unknown> | undefined,
): void {
if (!queryOptions) return;
const reserved = RESERVED_QUERY_OPTIONS.filter((key) => key in queryOptions);
if (reserved.length === 0) return;
throw new Error(
`[agent-harness] queryOptions.${reserved.join(", queryOptions.")} ${
reserved.length === 1 ? "is" : "are"
} owned by the Claude Agent SDK harness (streaming, approvals, resume, and cancellation depend on them). Remove ${reserved.length === 1 ? "it" : "them"}.`,
);
}
class ClaudeAgentSdkHarnessSession implements AgentHarnessSession {
readonly id: string;
private readonly cwd?: string;
private readonly permissionMode: AgentHarnessPermissionMode;
private readonly pendingPermissions = new Map<
string,
{
resolve: (
result: { behavior: "allow" } | { behavior: "deny"; message: string },
) => void;
}
>();
private queue: AsyncEventQueue<AgentHarnessEvent> | null = null;
private activeQuery: {
interrupt?: () => Promise<unknown>;
return?: (value?: unknown) => Promise<unknown>;
} | null = null;
private approvalCounter = 0;
private sessionId: string;
private stopped = false;
constructor(
private readonly sdk: any,
private readonly adapterOptions: ClaudeAgentSdkHarnessAdapterOptions,
private readonly sessionOptions: AgentHarnessCreateSessionOptions,
) {
const resume = sessionOptions.resumeState as
| ClaudeAgentSdkResumeState
| undefined
| null;
this.cwd = sessionOptions.cwd ?? resume?.cwd ?? adapterOptions.cwd;
this.permissionMode =
sessionOptions.permissionMode ??
adapterOptions.permissionMode ??
"allow-reads";
// Empty until the SDK reports its own session id on the first turn. The
// resume state is the only id that can actually resume a conversation, so
// never substitute a synthetic one for it — and never let two fresh
// sessions share a harness id, which would collide in the session store.
this.sessionId = resume?.sessionId ?? "";
this.id =
this.sessionId ||
sessionOptions.sessionId ||
`claude-agent-sdk-${randomUUID()}`;
}
async *streamTurn(
input: AgentHarnessTurnInput,
): AsyncIterable<AgentHarnessEvent> {
if (this.stopped) {
yield {
type: "error",
error: "[agent-harness] This Claude Agent SDK session was stopped.",
};
return;
}
const queue = new AsyncEventQueue<AgentHarnessEvent>();
this.queue = queue;
const state = createClaudeAgentSdkTurnState();
const abortController = new AbortController();
const abort = input.abortSignal ?? this.sessionOptions.signal;
const onAbort = () => abortController.abort();
if (abort) {
if (abort.aborted) onAbort();
else abort.addEventListener("abort", onAbort, { once: true });
}
const query = this.sdk.query({
prompt: promptFromTurnInput(input),
options: this.buildQueryOptions(abortController),
});
this.activeQuery = query;
void (async () => {
try {
for await (const message of query) {
if (typeof message?.session_id === "string" && message.session_id) {
this.sessionId = message.session_id;
}
for (const event of claudeAgentSdkMessageToEvents(message, state)) {
queue.push(event);
}
}
} catch (error) {
queue.push({
type: "error",
error: `[agent-harness] ${CLAUDE_AGENT_SDK_PACKAGE}: ${
error instanceof Error ? error.message : String(error)
}`,
});
} finally {
if (abort) abort.removeEventListener("abort", onAbort);
this.denyAllPending(
"The Claude Agent SDK turn ended before this approval was answered.",
);
this.activeQuery = null;
this.queue = null;
queue.close();
}
})();
yield* queue;
}
async approve(approval: AgentHarnessApproval): Promise<void> {
const pending = this.pendingPermissions.get(approval.id);
if (!pending) return;
this.pendingPermissions.delete(approval.id);
pending.resolve(
approval.approved
? { behavior: "allow" }
: {
behavior: "deny",
message: approval.message ?? "Denied by the user.",
},
);
}
async detach(): Promise<unknown> {
// A detached session is resumed by id on the next turn, so there is no
// process to keep alive between turns.
await this.destroy();
if (!this.sessionId) return undefined;
const state: ClaudeAgentSdkResumeState = { sessionId: this.sessionId };
if (this.cwd) state.cwd = this.cwd;
return state;
}
async stop(): Promise<unknown> {
this.stopped = true;
return this.detach();
}
async destroy(): Promise<void> {
const query = this.activeQuery;
this.activeQuery = null;
this.denyAllPending("The Claude Agent SDK session was stopped.");
if (!query) return;
try {
await query.interrupt?.();
} catch {
// The turn may already be finished; fall through to closing the stream.
}
try {
await query.return?.(undefined);
} catch {
// Same: an already-closed generator is not an error here.
}
}
private buildQueryOptions(
abortController: AbortController,
): Record<string, unknown> {
const {
env,
model,
pathToClaudeCodeExecutable,
queryOptions,
settingSources,
} = this.adapterOptions;
return {
...(queryOptions ?? {}),
...(this.cwd ? { cwd: this.cwd } : {}),
...(model ? { model } : {}),
...(settingSources ? { settingSources } : {}),
...(pathToClaudeCodeExecutable ? { pathToClaudeCodeExecutable } : {}),
...(env ? { env: { ...process.env, ...env } } : {}),
...(this.sessionOptions.instructions
? {
systemPrompt: {
type: "preset",
preset: "claude_code",
append: this.sessionOptions.instructions,
},
}
: {}),
...(this.sessionId ? { resume: this.sessionId } : {}),
...this.hostToolOptions(queryOptions),
// Text and thinking reach the transcript as deltas; without partial
// messages a turn would stream nothing until it finished.
includePartialMessages: true,
abortController,
canUseTool: this.createCanUseTool(),
};
}
/**
* Bridge the host tools handed to `createSession` into the SDK as an
* in-process MCP server. The SDK exposes them to the agent as
* `mcp__agent-native__<tool>`, which the permission mapping treats as risky
* — a host action is app state, not a workspace read.
*/
private hostToolOptions(
queryOptions: Record<string, unknown> | undefined,
): Record<string, unknown> {
const tools = this.sessionOptions.tools;
if (!tools || Object.keys(tools).length === 0) return {};
const server = createHostToolMcpServer(this.sdk, tools);
const existing = queryOptions?.mcpServers;
if (existing !== undefined && !isPlainRecord(existing)) {
throw new Error(
"[agent-harness] queryOptions.mcpServers must be a record of server configs when host tools are supplied.",
);
}
if (isPlainRecord(existing) && HOST_TOOL_MCP_SERVER in existing) {
throw new Error(
`[agent-harness] queryOptions.mcpServers.${HOST_TOOL_MCP_SERVER} collides with the host-tool server this harness mounts. Rename it.`,
);
}
return {
mcpServers: {
...(isPlainRecord(existing) ? existing : {}),
[HOST_TOOL_MCP_SERVER]: server,
},
};
}
private createCanUseTool() {
return async (
toolName: string,
input: Record<string, unknown>,
): Promise<
| { behavior: "allow"; updatedInput: Record<string, unknown> }
| { behavior: "deny"; message: string }
> => {
if (
claudeAgentSdkPermissionDecision(toolName, this.permissionMode) ===
"allow"
) {
return { behavior: "allow", updatedInput: input };
}
const queue = this.queue;
if (!queue) {
// No live turn to prompt on: deny rather than hang the agent.
return {
behavior: "deny",
message: "No approval surface is attached to this session.",
};
}
const id = `claude-agent-sdk-approval-${++this.approvalCounter}`;
const decision = new Promise<
{ behavior: "allow" } | { behavior: "deny"; message: string }
>((resolve) => {
this.pendingPermissions.set(id, { resolve });
});
queue.push({
type: "approval-request",
id,
tool: toolName,
message: `Approve ${toolName}?`,
input,
});
const resolved = await decision;
return resolved.behavior === "allow"
? { behavior: "allow", updatedInput: input }
: resolved;
};
}
private denyAllPending(message: string): void {
for (const [id, pending] of this.pendingPermissions) {
this.pendingPermissions.delete(id);
pending.resolve({ behavior: "deny", message });
}
}
}
/**
* A host tool as `createSession.tools` carries it: the AI SDK tool shape, whose
* `inputSchema` is a Zod object (what `defineAction` already holds).
*/
interface HostTool {
description?: string;
inputSchema: unknown;
execute: (input: Record<string, unknown>) => unknown;
}
function createHostToolMcpServer(
sdk: any,
tools: Record<string, unknown>,
): unknown {
if (
typeof sdk?.createSdkMcpServer !== "function" ||
typeof sdk?.tool !== "function"
) {
throw new Error(
`[agent-harness] ${CLAUDE_AGENT_SDK_PACKAGE} did not expose createSdkMcpServer()/tool(), so host tools cannot be bridged.`,
);
}
const definitions = Object.entries(tools).map(([name, definition]) =>
sdk.tool(
name,
hostToolDescription(name, definition),
hostToolInputShape(name, definition),
async (args: Record<string, unknown>) => {
const result = await (definition as HostTool).execute(args);
return {
content: [{ type: "text", text: stringifyHostToolResult(result) }],
};
},
),
);
return sdk.createSdkMcpServer({
name: HOST_TOOL_MCP_SERVER,
tools: definitions,
});
}
function hostToolDescription(name: string, definition: unknown): string {
const description = (definition as HostTool)?.description;
return typeof description === "string" && description
? description
: `Agent-Native host tool: ${name}`;
}
/**
* The SDK builds its tool schema from a Zod raw shape. `defineAction` inputs
* are Zod objects, so unwrap one level; anything else is rejected by name
* rather than silently registered with an empty schema, which would leave the
* agent calling the tool with no arguments.
*/
function hostToolInputShape(
name: string,
definition: unknown,
): Record<string, unknown> {
const tool = definition as HostTool | undefined;
if (typeof tool?.execute !== "function") {
throw new Error(
`[agent-harness] Host tool "${name}" has no execute() function.`,
);
}
const schema = tool.inputSchema as { shape?: unknown } | undefined;
if (isPlainRecord(schema?.shape)) return schema.shape;
if (isPlainRecord(schema)) return schema;
throw new Error(
`[agent-harness] Host tool "${name}" needs a Zod object inputSchema (or a raw Zod shape); the Claude Agent SDK cannot register a tool without one.`,
);
}
function stringifyHostToolResult(result: unknown): string {
if (typeof result === "string") return result;
if (result === undefined) return "";
try {
return JSON.stringify(result);
} catch {
return String(result);
}
}
function isPlainRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function promptFromTurnInput(input: AgentHarnessTurnInput): string {
if (input.prompt) return input.prompt;
const messages = input.messages ?? [];
const text = messages
.map((message) => harnessMessageToText(message))
.filter(Boolean)
.join("\n\n");
if (!text) {
throw new Error(
"[agent-harness] The Claude Agent SDK harness needs a prompt or at least one message with text content.",
);
}
return text;
}
function harnessMessageToText(message: AgentHarnessMessage): string {
if (typeof message.content === "string") return message.content;
return message.content
.map((part) => {
if (typeof part === "string") return part;
const record = part as { type?: unknown; text?: unknown };
return record?.type === "text" && typeof record.text === "string"
? record.text
: "";
})
.filter(Boolean)
.join("\n");
}
/**
* Map an Agent-Native permission mode onto a decision for a Claude Code tool
* call. Reads always run; workspace edits run under `allow-edits`; shell, MCP,
* and unknown tools prompt unless `allow-all`.
*/
export function claudeAgentSdkPermissionDecision(
toolName: string,
mode: AgentHarnessPermissionMode,
): "allow" | "prompt" {
if (mode === "allow-all") return "allow";
if (READ_ONLY_TOOLS.has(toolName)) return "allow";
if (mode === "allow-edits" && EDIT_TOOLS.has(toolName)) return "allow";
return "prompt";
}
/**
* Translate one SDK message into harness events. Tool results arrive on a
* later message than the call that produced them, so `state` carries the
* tool_use id → name/input correlation across a turn.
*/
export function claudeAgentSdkMessageToEvents(
message: any,
state: ClaudeAgentSdkTurnState,
): AgentHarnessEvent[] {
switch (message?.type) {
case "stream_event":
return streamEventToEvents(message.event);
case "assistant":
return assistantMessageToEvents(message, state);
case "user":
return userMessageToEvents(message, state);
case "result":
return resultMessageToEvents(message);
case "tool_progress":
return typeof message.tool_name === "string"
? [
{
type: "activity",
label: `${message.tool_name} is still running`,
tool: message.tool_name,
},
]
: [];
case "system":
return systemMessageToEvents(message);
default:
return [];
}
}
function streamEventToEvents(event: any): AgentHarnessEvent[] {
if (event?.type !== "content_block_delta") return [];
const delta = event.delta;
if (delta?.type === "text_delta" && typeof delta.text === "string") {
return delta.text ? [{ type: "text-delta", text: delta.text }] : [];
}
if (delta?.type === "thinking_delta" && typeof delta.thinking === "string") {
return delta.thinking
? [{ type: "thinking-delta", text: delta.thinking }]
: [];
}
return [];
}
function assistantMessageToEvents(
message: any,
state: ClaudeAgentSdkTurnState,
): AgentHarnessEvent[] {
// Text and thinking already streamed as deltas; only tool calls are new here.
const blocks = Array.isArray(message?.message?.content)
? message.message.content
: [];
const events: AgentHarnessEvent[] = [];
for (const block of blocks) {
if (block?.type !== "tool_use" || typeof block.id !== "string") continue;
const name = typeof block.name === "string" ? block.name : "tool";
state.toolCalls.set(block.id, { name, input: block.input });
events.push({
type: "tool-start",
id: block.id,
name,
input: block.input,
});
}
return events;
}
function userMessageToEvents(
message: any,
state: ClaudeAgentSdkTurnState,
): AgentHarnessEvent[] {
const blocks = Array.isArray(message?.message?.content)
? message.message.content
: [];
const events: AgentHarnessEvent[] = [];
for (const block of blocks) {
if (block?.type !== "tool_result") continue;
const id =
typeof block.tool_use_id === "string" ? block.tool_use_id : undefined;
const call = id ? state.toolCalls.get(id) : undefined;
if (id) state.toolCalls.delete(id);
const name = call?.name ?? "tool";
events.push({
type: "tool-done",
...(id ? { id } : {}),
name,
...(call ? { input: call.input } : {}),
result: block.content,
});
if (block.is_error) continue;
const fileChange = fileChangeEvent(name, call?.input);
if (fileChange) events.push(fileChange);
}
return events;
}
function fileChangeEvent(
toolName: string,
input: unknown,
): AgentHarnessEvent | null {
if (!EDIT_TOOLS.has(toolName)) return null;
const record = (input ?? {}) as Record<string, unknown>;
const key = FILE_PATH_KEYS.find(
(candidate) => typeof record[candidate] === "string",
);
if (!key) return null;
return {
type: "file-change",
path: record[key] as string,
// Write creates or overwrites and the result does not say which; the edit
// tools always target an existing file.
operation: toolName === "Write" ? "unknown" : "update",
};
}
function resultMessageToEvents(message: any): AgentHarnessEvent[] {
const events: AgentHarnessEvent[] = [];
const usage = message?.usage;
const inputTokens = numberOrUndefined(usage?.input_tokens);
const outputTokens = numberOrUndefined(usage?.output_tokens);
const costUsd = numberOrUndefined(message?.total_cost_usd);
if (
inputTokens !== undefined ||
outputTokens !== undefined ||
costUsd !== undefined
) {
events.push({
type: "usage",
...(inputTokens !== undefined ? { inputTokens } : {}),
...(outputTokens !== undefined ? { outputTokens } : {}),
...(inputTokens !== undefined && outputTokens !== undefined
? { totalTokens: inputTokens + outputTokens }
: {}),
...(costUsd !== undefined ? { costCents: costUsd * 100 } : {}),
});
}
if (message?.is_error || message?.subtype !== "success") {
const errors = Array.isArray(message?.errors) ? message.errors : [];
events.push({
type: "error",
error:
errors
.filter((entry: unknown) => typeof entry === "string")
.join("; ") ||
`Claude Agent SDK run ended: ${message?.subtype ?? "unknown error"}`,
...(typeof message?.subtype === "string"
? { code: message.subtype }
: {}),
});
return events;
}
events.push({
type: "done",
...(typeof message?.stop_reason === "string"
? { reason: message.stop_reason }
: {}),
});
return events;
}
function systemMessageToEvents(message: any): AgentHarnessEvent[] {
if (message?.subtype === "compact_boundary") {
const trigger = message?.compact_metadata?.trigger;
return [
{
type: "compaction",
...(typeof trigger === "string"
? { summary: `Compacted conversation (${trigger})` }
: {}),
},
];
}
if (message?.subtype === "permission_denied") {
const tool =
typeof message?.tool_name === "string" ? message.tool_name : "tool";
return [{ type: "activity", label: `Denied ${tool}`, tool }];
}
return [];
}
function numberOrUndefined(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value)
? value
: undefined;
}
// --- inlined from packages/core/src/agent/harness/async-event-queue.ts -------
/** Minimal async queue bridging callback-driven harness updates to an async iterable. */
class AsyncEventQueue<T> implements AsyncIterable<T> {
private readonly values: T[] = [];
private readonly resolvers: Array<(result: IteratorResult<T>) => void> = [];
private closed = false;
push(value: T): void {
if (this.closed) return;
const resolver = this.resolvers.shift();
if (resolver) resolver({ value, done: false });
else this.values.push(value);
}
close(): void {
if (this.closed) return;
this.closed = true;
let resolver: ((result: IteratorResult<T>) => void) | undefined;
while ((resolver = this.resolvers.shift())) {
resolver({ value: undefined as never, done: true });
}
}
[Symbol.asyncIterator](): AsyncIterator<T> {
return {
next: (): Promise<IteratorResult<T>> => {
if (this.values.length > 0) {
return Promise.resolve({
value: this.values.shift() as T,
done: false,
});
}
if (this.closed) {
return Promise.resolve({ value: undefined as never, done: true });
}
return new Promise<IteratorResult<T>>((resolve) => {
this.resolvers.push(resolve);
});
},
};
}
}
// --- registration -----------------------------------------------------------
/**
* Startup plugin: make `claude-agent-sdk` resolvable by name, so anything that
* calls `resolveAgentHarness("claude-agent-sdk")` gets this adapter.
*
* Auth comes from the process environment the app runs under. On a privatecloud
* VM that is CLAUDE_CODE_OAUTH_TOKEN, delivered to the systemd unit through
* EnvironmentFile=%h/.config/privatecloud/agent.env — a login shell's .bashrc
* export does NOT reach a service, so without that line the harness starts and
* then fails on its first turn.
*/
export default function claudeAgentSdkHarnessPlugin(): void {
registerAgentHarness({
name: "claude-agent-sdk",
label: "Claude Code (Agent SDK)",
description:
"Runs Claude Code through the first-party Claude Agent SDK on this app's own Claude credentials.",
installPackage: CLAUDE_AGENT_SDK_PACKAGE,
capabilities: {
sandbox: false,
resumable: true,
approvals: true,
hostTools: true,
fileEvents: true,
},
create: (config) =>
createClaudeAgentSdkHarnessAdapter(
(config ?? {}) as ClaudeAgentSdkHarnessAdapterOptions,
),
});
}
+77
View File
@@ -0,0 +1,77 @@
import {
ensureAdditiveColumns,
getDbExec,
runMigrations,
} from "@agent-native/core/db";
import * as schema from "../db/schema.js";
const runStocksMigrations = runMigrations(
[
{
version: 1,
name: "stocks-table",
sql: `CREATE TABLE IF NOT EXISTS stocks (
id TEXT PRIMARY KEY,
symbol TEXT NOT NULL,
company_name TEXT NOT NULL,
exchange TEXT NOT NULL DEFAULT 'NASDAQ',
sector TEXT,
currency TEXT NOT NULL DEFAULT 'USD',
price REAL NOT NULL DEFAULT 0,
change_percent REAL NOT NULL DEFAULT 0,
market_cap INTEGER,
watchlisted BOOLEAN NOT NULL DEFAULT FALSE,
owner_email TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)`,
},
{
version: 2,
name: "stocks-owner-symbol-unique-index",
sql: `CREATE UNIQUE INDEX IF NOT EXISTS idx_stocks_owner_symbol
ON stocks (owner_email, symbol)`,
},
{
version: 3,
name: "stocks-owner-watchlisted-index",
sql: `CREATE INDEX IF NOT EXISTS idx_stocks_owner_watchlisted
ON stocks (owner_email, watchlisted)`,
},
],
{ table: "stocks_migrations" },
);
function isDrizzleTable(value: unknown): value is object {
return (
!!value &&
typeof value === "object" &&
Object.getOwnPropertySymbols(value).some((s) =>
s.toString().includes("drizzle"),
)
);
}
const schemaTables = Object.values(schema).filter(isDrizzleTable);
export default async (nitroApp: any): Promise<void> => {
await runStocksMigrations(nitroApp);
try {
const summary = await ensureAdditiveColumns({
db: getDbExec(),
tables: schemaTables,
});
if (summary.errors.length > 0) {
console.warn(
"[db] ensureAdditiveColumns completed with errors:",
summary.errors,
);
}
} catch (err) {
console.warn(
"[db] ensureAdditiveColumns failed (non-fatal):",
err instanceof Error ? err.message : err,
);
}
};
@@ -0,0 +1,99 @@
// Vendored by builder-app alongside claude-agent-sdk-harness.ts. See that
// file's header for why the harness is vendored rather than installed.
//
// Starts one Claude Agent SDK turn for a thread and returns its runId. The turn
// itself streams over the framework's existing run routes — there is no second
// streaming mechanism here:
//
// POST /api/claude-agent/turn { threadId, prompt } -> { runId, sessionId }
// GET /_agent-native/runs/<runId>/events (SSE transcript)
// POST /_agent-native/runs/<runId>/abort (cancel)
//
// A streaming route is one of the documented exceptions to actions-first; the
// start call is kept here with it so the two stay in one place.
import { randomUUID } from "node:crypto";
import {
ensureAgentHarnessSessionTables,
getLatestAgentHarnessSessionForThread,
resolveAgentHarness,
startAgentHarnessRun,
} from "@agent-native/core/agent/harness";
import { getSession } from "@agent-native/core/server";
import { runWithRequestContext } from "@agent-native/core/server/request-context";
import { defineEventHandler, readBody, setResponseStatus } from "h3";
/** Where the agent works. Defaults to the app's own directory. */
const WORKSPACE_DIR = process.env.CLAUDE_AGENT_WORKSPACE_DIR || process.cwd();
// Custom `/api/*` routes are not wrapped in the framework's per-request
// AsyncLocalStorage context the way `/_agent-native/actions/*` are — only the
// action dispatcher resolves the session and calls `runWithRequestContext`
// itself. Do the same resolution here so `ownerEmail`/`orgId` are correct
// for both a real signed-in session and the AUTH_DISABLED dev fallback.
export default defineEventHandler(async (event) => {
const session = await getSession(event);
const ownerEmail = session?.email;
if (!ownerEmail) {
setResponseStatus(event, 401);
return { error: "Sign in to run the agent." };
}
return runWithRequestContext(
{ userEmail: ownerEmail, orgId: session?.orgId ?? undefined },
async () => {
const body = (await readBody(event)) as {
threadId?: unknown;
prompt?: unknown;
} | null;
const threadId = typeof body?.threadId === "string" ? body.threadId : "";
const prompt =
typeof body?.prompt === "string" ? body.prompt.trim() : "";
if (!threadId || !prompt) {
setResponseStatus(event, 400);
return { error: "threadId and prompt are required." };
}
await ensureAgentHarnessSessionTables();
// Resume this thread's previous SDK session instead of replaying history.
// Scoped to this harness by name: a thread that also ran another harness
// would otherwise hand us that harness's resumeState, which this one
// cannot interpret. A thread with no prior session starts fresh.
const previous = await getLatestAgentHarnessSessionForThread(
threadId,
"claude-agent-sdk",
);
const resumeState = previous?.resumeState;
const adapter = resolveAgentHarness("claude-agent-sdk", {
cwd: WORKSPACE_DIR,
// Reads run; edits and shell prompt. Loosen per app only deliberately.
permissionMode: "allow-reads",
});
const runId = randomUUID();
startAgentHarnessRun({
runId,
threadId,
adapter,
input: { prompt },
createSession: {
sessionId: previous?.id,
resumeState,
cwd: WORKSPACE_DIR,
permissionMode: "allow-reads",
},
ownerEmail,
orgId: session?.orgId ?? null,
});
return {
runId,
sessionId: previous?.id ?? null,
events: `/_agent-native/runs/${runId}/events`,
};
},
);
});