# hudson — full documentation # Admin resources > Source: https://hudsonkit.com/docs/admin-resources/ Schema-driven operator admin — hosts mount declared resources, not product-specific UIs # Admin resources Hudson **admin** is a generic operator module. It is **not** coupled to inference credits, prepare, auth, or any other domain. Domains that want an operator surface **declare** resources (Zod row shape + columns + stats + actions + a data loader). The admin shell only knows how to: 1. List resources for a host 2. Validate rows / action inputs against schemas 3. Render tables, stat cards, and action forms 4. Run loaders / actions the host injects Credits is the first domain adapter. Admin does not import ledger semantics. ## Separation of concerns | Layer | Owns | Coupling | |-------|------|----------| | **Domain** (e.g. inference credits) | Protocol, ledger, rate card, stores | Tight, shared across apps | | **Admin resource declaration** | Zod row schema, columns, actions as *projections* | Domain exports optional admin shapes | | **Admin shell** (`@hudsonkit/admin`) | Render + validate + route | **None** to domain internals | | **Host** (e.g. `admin.uselinea.com`) | Auth, DB/bindings, which resources to mount | Config only | Inference stays smart. Admin stays interchangeable. ## Declare a resource ```ts import { z } from "zod"; import { defineResource, defineAdmin } from "@hudsonkit/admin"; const WalletRow = z.object({ userId: z.string(), available: z.number().int(), held: z.number().int(), periodSpent: z.number().int(), lifetimeSpent: z.number().int(), }); const wallets = defineResource({ id: "credits.wallets", title: "Wallets", description: "User credit balances for this account", row: WalletRow, columns: [ { key: "userId", label: "User", format: "code" }, { key: "periodSpent", label: "Period", format: "int" }, { key: "available", label: "Available", format: "int" }, { key: "held", label: "Held", format: "int" }, { key: "lifetimeSpent", label: "Lifetime", format: "int" }, ], // Host injects how to load — admin never knows D1 vs HTTP vs memory. list: async (ctx) => ctx.load("credits.wallets"), }); ``` ### Actions ```ts const grant = defineAction({ id: "credits.grant", title: "Grant credits", input: z.object({ userId: z.string().min(1), credits: z.number().int().positive(), reason: z.string().optional(), }), fields: [ { name: "userId", label: "User id", kind: "text" }, { name: "credits", label: "Credits", kind: "number", defaultValue: 500_000 }, { name: "reason", label: "Reason", kind: "text", defaultValue: "admin_grant" }, ], run: async (input, ctx) => ctx.run("credits.grant", input), }); ``` ### Host config ```ts const admin = defineAdmin({ title: "Linea", resources: [wallets, entries], actions: [grant], stats: async (ctx) => ctx.load("credits.stats"), // optional top cards }); // Worker: return handleAdminRequest(request, admin, { auth: (req) => checkAdminToken(req), context: () => ({ host: {}, load: async (name) => { /* D1 queries */ }, run: async (name, input) => { /* grant */ }, }), }); ``` ## Package Canonical implementation: [`packages/web/admin`](../packages/web/admin) → npm name **`@hudsonkit/admin`**. | Export | Purpose | |--------|---------| | `defineResource` / `defineAction` / `defineAdmin` | Declaration helpers | | `renderAdminHtml` | Zero-React HTML page for Workers / edge | | `handleAdminRequest` | GET list + POST action routing | | `defineCreditsAdmin` | Optional adapter: Zod shapes for wallets/entries/grant (loaders still host-owned) | ## What admin must not do - Import `CreditStore`, rate cards, or hold/commit semantics - Hard-code SQL table names inside the shell - Own product auth (host passes `auth`) - Require React — first renderer is HTML so CF Workers and simple hosts work React/`HudTable` binding is a later surface over the same declarations. ## v1 scope (shipped) 1. Zod-declared resources + actions 2. HTML renderer (flat hierarchy, no product chrome) 3. Host context: `load` / `run` / `auth` 4. Credits adapter declarations (shapes only) 5. Linea host: `admin.uselinea.com` mounts credits via loaders on `CREDITS_DB` ## Later - React panel over the same `defineResource` output - Prepare / flags / auth as additional resource packs - Multi-account switcher as host chrome, not shell core --- # AI > Source: https://hudsonkit.com/docs/ai/ Provider-neutral inference: Claude, OpenAI, OpenRouter # AI ## Overview `HudAI` is the Apple-side inference primitive in HudsonKit. It handles one model turn at a time — provider setup, credentials, streaming, typed tool calls, recoverable errors — behind a single `HudAIClient`. Swap providers by passing a different adapter. Design rationale lives in the internal engineering spec at `docs/specs/hud-ai-framework.md`. ## HudAIClient Construct with a provider adapter and a credential source (typically a `HudVault`). ```swift import HudsonAI import HudsonUI let client = HudAIClient(provider: HudAIProviders.Claude(), hudVault: vault) let response = try await client.complete( HudAIRequest( messages: [.user("Summarize today's standup notes.")], system: "You are a terse assistant." ) ) print(response.text) ``` | Name | Type | Default | Description | |------|------|---------|-------------| | `provider` | `HudAIProviderAdapter` | `AnthropicHudAIAdapter()` | Vendor adapter. | | `model` | `String?` | adapter default | Default model id; per-request overrides. | | `vault` / `hudVault` | `HudAICredentialSource` / `HudVault` | — | Key source. Raw keys are never accepted in requests. | | `defaults` | `HudAIDefaults` | — | Temperature, max output tokens, cache policy, timeout. | | `routeDefault` | `HudAIRoute` | `.local` | `.local`, `.auto`, or `.paired(...)`. Paired is reserved and throws. | | `urlSession` | `URLSession` | `.shared` | Inject for tests or custom transport. | ## Providers Adapters live under the `HudAIProviders` namespace so call sites read like vendor selection: ```swift HudAIProviders.Claude() // Anthropic — implemented HudAIProviders.Anthropic() // alias of Claude HudAIProviders.OpenAI() // implemented HudAIProviders.OpenRouter(appTitle: "MyApp", siteURL: url) // implemented HudAIProviders.Grok() // stub — throws .unsupportedFeature ``` Each adapter declares a `credentialKey` read from the vault: `anthropic_key`, `openai_key`, `openrouter_key`, `grok_key`. OpenRouter expects namespaced model ids like `openai/gpt-4o-mini`. ## Requests and messages `HudAIMessage` carries a role (`.user`, `.assistant`, `.tool`) and `HudAIContentPart`s (`.text`, `.toolCall`, `.toolResult`). Helpers: `.user(_:)`, `.assistant(_:)`, `.toolResult(_:)`. ```swift let request = HudAIRequest( messages: [.user("What's the weather in Berlin?")], system: "Be concise.", tools: [weatherTool], toolChoice: .auto, temperature: 0.2, maxOutputTokens: 512 ) ``` ## Streaming `stream(_:)` returns `AsyncThrowingStream` of vendor-neutral events. ```swift for try await event in client.stream(request) { switch event { case .textDelta(_, let text): print(text, terminator: "") case .reasoningDelta(_, let text): print("[think] \(text)") case .toolCallReady(let call): handle(call) case .usage(let usage): record(usage) case .completed(let response): finish(response) case .failed(let error): throw error default: break } } ``` Other events: `.started`, `.toolCallStarted`, `.toolCallInputDelta` (partial JSON), `.toolResultAccepted`, `.cancelled`. ## Tool calls Tools take a JSON Schema plus a Swift `Decodable` input type. HudAI validates the model's input, so call sites work in typed values, not raw JSON. ```swift struct WeatherInput: Decodable { let city: String; let units: String? } let weatherTool = HudAIToolDefinition( name: "get_weather", description: "Look up current weather.", inputSchema: [ /* JSON Schema as [String: HudAIJSONValue] */ ], inputType: WeatherInput.self ) // On .toolCallReady: let input = try call.decodeInput(WeatherInput.self) let text = try await fetchWeather(city: input.city, units: input.units) let toolMessage = HudAIMessage.toolResult( HudAIToolResult(toolCallID: call.id, content: .text(text)) ) // Append to messages, call client.stream again to continue the turn. ``` Use `HudAIToolDefinition.untyped(...)` to skip the Swift type — input arrives as raw `HudAIJSONValue`. ## Credentials via HudVault `HudVault` is HudsonKit's keychain wrapper. Store keys with the credential keys above, then pass the vault to the client: ```swift try vault.set("anthropic_key", value: Data(apiKey.utf8)) let client = HudAIClient(provider: HudAIProviders.Claude(), hudVault: vault) ``` If a key is missing or empty, requests fail fast with `.credentialsMissing` or `.credentialsInvalid` before any network call. ## Errors All failures surface as `HudAIError`. Each case carries the `provider`; rejection and rate-limit cases also include HTTP status and request id. | Case | When | |------|------| | `.credentialsMissing` | Vault has no value for the adapter's key. | | `.credentialsInvalid` | Value is empty or non-UTF8. | | `.networkUnavailable` | Transport-level failure. | | `.providerRejectedRequest` | 4xx from the provider. | | `.rateLimited` | 429 / quota exhaustion. Retryable. | | `.overloaded` | Provider shedding load. Retryable. | | `.timeout` | Exceeded `defaults.timeout`. Retryable. | | `.cancelled` | Task was cancelled. | | `.toolInputDecodeFailed` | Tool input doesn't decode to declared type. | | `.unsupportedFeature` | Path not implemented (e.g. Grok stub). | | `.pairingChannelUnavailable` | `.paired` route, not in v1. | | `.providerProtocolError` | Stream produced unexpected payload. | `error.isRetryable` flags transient cases. `error.httpStatus` and `error.providerRequestID` extract details for logging. ## Keeping model catalogs current Hudson tracks `@earendil-works/pi-ai` through `latest`, without a root version override. Run `bun run update:models` at the repository root to refresh the runtime and lockfile. The lockfile records the tested installation; a frozen install alone does not fetch newer releases. The web model endpoint reads the runtime registry for all supported providers. Copilot discovery narrows that registry to account-visible chat models when available. Settings and developer pickers refresh on window focus, without a Hudson model allowlist. Saved model preferences remain host-owned. The adapter uses pi-ai's supported `compat` entrypoint for its existing stream API, requiring pi-ai 0.85.1 or newer. --- # api > Source: https://hudsonkit.com/docs/api/ # API Reference Every `hudsonkit` export, organized by subpath. Types are authoritative in [`packages/web/hudsonkit/src/types/`](../packages/web/hudsonkit/src/types/); this doc is a map, not the source of truth. ## Subpath exports | Subpath | Purpose | |-----------------------------------|----------------------------------------------------------------------| | `hudsonkit` | Types, hooks, platform adapter, AI component, utilities (main entry) | | `hudsonkit/app-shell` | `AppShell` — single-app default shell | | `hudsonkit/shell` | `WorkspaceShell` + all chrome/overlays/canvas/windows (back-compat barrel) | | `hudsonkit/chrome` | Chrome primitives: `Frame`, `NavigationBar`, `SidePanel`, `StatusBar`, `CommandDock`, `Minimap`, `ZoomControls`, `AnimationTimeline` | | `hudsonkit/overlays` | `CommandPalette`, `TerminalDrawer` (no `ContextMenu`) | | `hudsonkit/context-menu` | `HudsonContextMenu` (opt-in; pulls `motion` + `@base-ui-components/react`) | | `hudsonkit/behaviors` | Base UI wrappers: `HudTooltip`(+Provider), `HudMenu`, `HudPopover`, `HudSelectBase` + shared `menuChrome` (opt-in; pulls `@base-ui-components/react`) | | `hudsonkit/canvas` | `Canvas` (pan/zoom world) | | `hudsonkit/windows` | `AppWindow` (draggable/resizable window frame) | | `hudsonkit/theme` | Design tokens: `SHELL_THEME`, `PANEL_STYLES`, `Z_LAYERS`, `LAYOUT`, etc. | | `hudsonkit/styles` | **Pre-compiled CSS bundle** — import once to get every utility class used by SDK chrome | | `hudsonkit/controls` | Client controls: params, `CodeViewer`, `CodeEditor`, `TextDocumentSurface`, `TextDiffSurface`; editor/markdown/diff runtimes are optional peers loaded per component — see controls.md | | `hudsonkit/cache` | `createHudsonCache`, `hudsonCache`, `useCachedResource` — TTL/SWR cache with tag invalidation; see [Cache](./cache.md) | | `hudsonkit/voice` | Opt-in voice plugin — see voice.md | | `hudsonkit/observability` | `HLogger`, `HMetrics`, `HObservability`, `HSpan`, `HTrace` — see observability.md | ## Types Importable from `hudsonkit`: | Type | Source | |-----------------------------------|-------------------------------------------| | `HudsonApp` | `types/app.ts` — the app contract | | `AppTool` | Tool panel entry for Inspector accordion | | `AppManifest` | Serializable app capability snapshot | | `AppSettingsConfig`, `AppSettingField`, `AppSettingsSection` | Settings UI schema | | `SearchConfig` | Nav bar search wiring | | `StatusColor` | `'emerald' \| 'amber' \| 'red' \| 'neutral'` | | `TakeoverState` | Return type of `useTakeover` hook — `{ active, dismissible, onDismiss? }` | | `MultiInstanceMode` | `'singleton' \| 'spawnable' \| 'duplicable'` | | `HudsonWorkspace`, `WorkspaceAppConfig`, `CanvasParticipation` | `types/workspace.ts` | | `AppIntent`, `IntentCategory`, `IntentParameter`, `CatalogAppEntry`, `IntentCatalog` | `types/intent.ts` | | `ServiceDefinition`, `ServiceDependency`, `ServiceRecord`, `ServiceAction`, `ServiceStatus` | `types/service.ts` | | `AppOutput`, `AppInput`, `AppPorts`, `PipeDefinition` | `types/port.ts` | | `CommandOption`, `ContextMenuEntry`, `ContextMenuAction`, `ContextMenuSeparator`, `ContextMenuGroup` | `components/overlays` | | `HudsonTheme` | `'light' \| 'dark' \| 'system'` | | `HudsonTemplate` | `'hudson' \| 'editorial'` | ## Hooks (from `hudsonkit`) | Hook | Purpose | |---------------------------------------------------|------------------------------------------------| | `usePersistentState(key, initial)` | localStorage-backed state, SSR-safe, cross-tab | | `useDebouncedPersistentState(key, initial, ms)` | Like above, with write debounce | | `useSaveIndicator()` | Status indicator for save operations | | `useAppSettings(appId)` | Read/write current app's settings | | `useHudsonAI(options)` | Chat transport for the workspace AI panel | | `useAssistant(options)` | Wires app intents + commands to an AI chat; see Assistant section below | | `useCachedResource(key, loader, options?)` | Subscribed async resource cache read; see Cache section below | | `useTerminalRelay(options)` | WebSocket bridge to the terminal relay server | | `useTheme()` | Read/set current theme and template; throws outside `ThemeProvider` | | `useOptionalTheme()` | Like `useTheme()` but returns `null` outside provider | | `useInstance()` | Returns `{ instanceId, appId }`; throws outside `InstanceProvider` | | `useOptionalInstance()` | Like `useInstance()` but returns `null` outside provider | Returned types (also exported): `AppSettingsValues`, `HudsonAIChat`, `UseHudsonAIOptions`, `AIAttachment`, `AssistantChat`, `UseAssistantOptions`, `TerminalRelayHandle`, `UseTerminalRelayOptions`, `RelayStatus`. ## Cache (from `hudsonkit/cache`) `hudsonkit/cache` is the shared cache substrate for values that can be refetched or recomputed. It supports TTL, stale-while-revalidate windows, in-flight async dedupe, tag invalidation, optional local/session storage, hydrate/dehydrate, and subscribed React reads. | Export | Kind | Description | |---|---|---| | `createHudsonCache(options?)` | Factory | Create a scoped cache with namespace, default TTL/SWR, `maxEntries`, optional storage, and a custom clock. | | `hudsonCache` | Instance | Default memory cache with `namespace: 'hudson'`. | | `useCachedResource(key, loader, options?)` | Hook | Read/load a cache entry and subscribe to writes, deletes, and invalidations. | Cache instances expose `read`, `get`, `has`, `set`, `getOrLoad`, `revalidate`, `delete`, `clear`, `invalidateTag`, `keys`, `prune`, `dehydrate`, `hydrate`, and `subscribe`. Types: `HudsonCache`, `HudsonCacheEntry`, `HudsonCacheRead`, `HudsonCacheEvent`, `HudsonCacheStatus`, `HudsonCacheStorage`, `HudsonCacheLoadOptions`, `HudsonCacheSetOptions`, `HudsonCacheOptions`, `HudsonCacheLoader`, `CachedResourceStatus`, `UseCachedResourceOptions`, `UseCachedResourceResult`. See [Cache](./cache.md) for the policy guidance: when to use Hudson's primitive, when to keep data in Provider state, and when to reach for TanStack Query, IndexedDB, Cache API, or Next.js caching instead. ## Components ### From `hudsonkit` - `AI` — chat panel component, paired with `useHudsonAI` - `Assistant` — assistant panel component, paired with `useAssistant` - `TerminalRelay`, `captureWorkspace` — terminal relay component + screenshot helper - `ZoomControls` — reusable widget (also re-exported from `/chrome`) ### From `hudsonkit/app-shell` #### `AppShell` props | Prop | Type | Default | Description | |-------------------|--------------------|--------------|---------------------------------------------------------------------------| | `app` | `HudsonApp` | — | Required. The app to render. | | `assistant` | `boolean` | `true` | Enable the built-in Assistant tab in the bottom drawer. | | `defaultTheme` | `HudsonTheme` | `'system'` | Initial theme; user can switch at runtime. | | `defaultTemplate` | `HudsonTemplate` | `'hudson'` | Initial template. | | `managedTheme` | `boolean` | `true` | When `false`, assumes a parent `ThemeProvider` already exists. | ### From `hudsonkit/shell` (back-compat barrel) All of: `WorkspaceShell`, `AppShell`, `Frame`, `NavigationBar`, `SidePanel`, `StatusBar`, `CommandDock`, `Minimap`, `ZoomControls`, `AnimationTimeline`, `Canvas`, `AppWindow`, `TerminalDrawer`, `CommandPalette`, `HudsonContextMenu`, design tokens. ### From `hudsonkit/context-menu` - `HudsonContextMenu` — right-click menu component. Pulls `motion/react` + `@base-ui-components/react`. ## Theme (from `hudsonkit`) | Export | Kind | Description | |----------------------|-----------|-------------------------------------------------------------------| | `ThemeProvider` | Component | Wraps a subtree; manages `data-hudson-theme` / `data-hudson-template` on root. | | `HudsonThemeScript` | Component | Inline script for SSR flash-free theme init. Render before ``. | | `useTheme` | Hook | Returns `{ theme, resolvedTheme, template, setTheme, setTemplate }`. Throws outside provider. | | `useOptionalTheme` | Hook | Same return shape, or `null` outside provider. | Types: `HudsonTheme`, `HudsonTemplate`, `ThemeProviderProps`. `ThemeProviderProps`: `{ children, defaultTheme?, defaultTemplate?, storageKey?, rootElement? }`. See [Theming](./theming.md) for usage. ## Instance context (from `hudsonkit`) | Export | Kind | Description | |-----------------------|-----------|----------------------------------------------------------------| | `InstanceProvider` | Component | `{ instanceId, appId, children }` — scopes per-instance state. | | `useInstance` | Hook | Returns `InstanceContextValue`; throws outside provider. | | `useOptionalInstance` | Hook | Returns `InstanceContextValue \| null`. | Types: `InstanceContextValue` — `{ instanceId: string; appId: string }`. ## Assistant (from `hudsonkit`) - `Assistant` — panel component. Props: `{ app: HudsonApp; commands: CommandOption[] }`. Renders a chat UI wired to the app's declared intents. - `useAssistant(options)` — lower-level hook. Builds context from `app.intents`, wires a `dispatch` tool call to the live `commands` array, delegates to `useHudsonAI` internally. | `UseAssistantOptions` field | Type | Description | |-----------------------------|--------------------------------|----------------------------------------------------| | `app` | `HudsonApp` | Required. | | `commands` | `CommandOption[]` | Live commands from `app.hooks.useCommands()`. | | `state` | `Record` | Optional snapshot included in model context. | | `provider` | `string` | Provider override (e.g. `'anthropic'`). | | `model` | `string` | Model override. | | `onFinish` | `ChatOnFinishCallback` | Called when an assistant turn finishes. | `AssistantChat` is an alias for `HudsonAIChat`. Note: `useAssistant` is app-intent aware; `useHudsonAI` is the lower-level workspace AI panel transport. ## Observability (from `hudsonkit`) | Export | Kind | Description | |-------------------------|----------|----------------------------------------------------------| | `HLogger` | Class | Structured log emitter. | | `HMetrics` | Class | Metric counter/gauge/histogram emitter. | | `HObservability` | Class | Core bus; call `HObservability.global()` for the default instance. | | `HObservabilityDefault` | Instance | Pre-built `HObservability.global()` singleton. | | `HSpan` | Class | Active trace span handle. | | `HTrace` | Class | Trace builder. | Exported types: `HLogEvent`, `HLogInput`, `HLogLevel`, `HMetricEvent`, `HMetricInput`, `HMetricType`, `HObservabilityOptions`, `HObservation`, `HObservationBase`, `HObservationData`, `HObservationKind`, `HObservationSink`, `HObservationTags`, `HSubscribeOptions`, `HTraceInput`, `HTraceSpan`, `HTraceStatus`, `HUnsubscribe`. These are advanced — refer to `src/observability.ts` and `hudsonkit/observability` for the full surface. ## Platform adapter (from `hudsonkit`) - `WEB_ADAPTER` — default web platform adapter - `PlatformProvider` — wraps a subtree with a specific adapter - `usePlatform()`, `usePlatformLayout()` — consumer hooks - Types: `PlatformAdapter`, `PlatformLayout` ## Utilities (from `hudsonkit`) - `sounds` — Web Audio event sounds: `blipUp`, `blipDown`, `click`, `whoosh`, `thock`, `pop`, `confirm`, `error`, `chime`, `tick`, `slideIn`, `slideOut`, `boot`, `ping`, `type` - `logEvent`, `FRAME_LOG_EVENT` — instrumented event bus - `FrameLogEntry` type - `worldToScreen`, `screenToWorld` — canvas coordinate math - `deriveManifest(app)` — build an `AppManifest` from a `HudsonApp` - `probeVoxAvailability()` — check voice availability; returns `VoxAvailability` ## The `HudsonApp` interface at a glance ```ts interface HudsonApp { // Identity id: string; name: string; description?: string; mode: 'canvas' | 'panel'; icon?: ReactNode; // Multi-instance behaviour (default: 'singleton') multiInstance?: MultiInstanceMode; // 'singleton' | 'spawnable' | 'duplicable' // Panel config leftPanel?: { title: string; icon?: ReactNode; headerActions?: React.FC }; rightPanel?: { title: string; icon?: ReactNode; headerActions?: React.FC }; // State owner — disabled/visible/focused reflect workspace mount state Provider: React.FC<{ children: ReactNode; disabled?: boolean; visible?: boolean; focused?: boolean }>; // Right sidebar tool accordion tools?: AppTool[]; // Slots slots: { Content: React.FC; LeftPanel?: React.FC; RightPanel?: React.FC; // @deprecated — use Inspector Inspector?: React.FC; LeftFooter?: React.FC; Terminal?: React.FC; /** Full-viewport overlay rendered above shell chrome. Shell marks * background `inert` + `aria-hidden` while active. */ Takeover?: React.FC; }; // Shell bridge hooks: { useCommands: () => CommandOption[]; useStatus: () => { label: string; color: StatusColor }; useSearch?: () => SearchConfig; useNavCenter?: () => ReactNode | null; useNavActions?: () => ReactNode | null; useLayoutMode?: () => 'canvas' | 'panel' | 'focus'; useActiveToolHint?: () => string | null; usePortOutput?: () => (portId: string) => unknown | null; usePortInput?: () => (portId: string, data: unknown) => void; /** Return active:true to mount Takeover above chrome. Shell is * stateless about dismissal — the hook's own state drives it. */ useTakeover?: () => TakeoverState | null; }; // Optional integrations intents?: AppIntent[]; manifest?: AppManifest; settings?: AppSettingsConfig; ports?: AppPorts; services?: ServiceDependency[]; } ``` See [Building apps](./building-apps.md) for a walkthrough. --- # apple-binary-distribution > Source: https://hudsonkit.com/docs/apple-binary-distribution/ # Apple Binary Distribution Hudson's native Apple package can be released as SwiftPM binary targets so a public downstream app can depend on Hudson UI modules without vendoring Hudson source. ## Package shape Use one public binary Swift package, `arach/hudsonkit-xcframework`. That package's manifest contains multiple `.binaryTarget` entries, but the consumer sees a single package dependency with the `HudsonUI` and `HudsonShell` products. The multiple binary targets are required because SwiftPM binary targets cannot declare their own transitive dependencies. The product target lists provide the closure Swift needs at compile/link time while keeping every module prebuilt. Default shape: - Swift packages: 2 total (`Hudson` source package, `HudsonKitXCFramework` public binary package) - Public products in `HudsonKitXCFramework`: 2 (`HudsonUI`, `HudsonShell`) - Binary targets/artifact zips today: 4 (`HudsonLive`, `HudsonObservability`, `HudsonUI`, `HudsonShell`) ## Build a release From the repo root: ```bash scripts/apple/build-xcframeworks.sh --version 1.2.0 ``` The script writes artifacts to `dist/apple-xcframeworks/1.2.0/` by default: - `HudsonLive-1.2.0.xcframework.zip` - `HudsonObservability-1.2.0.xcframework.zip` - `HudsonUI-1.2.0.xcframework.zip` - `HudsonShell-1.2.0.xcframework.zip` - `checksums.txt` - `Package.swift` Upload the zip files to a public GitHub Release or CDN. The generated `Package.swift` uses `https://github.com/arach/hudsonkit-xcframework/releases/download/VERSION` unless you pass a different `--base-url`. ```bash scripts/apple/build-xcframeworks.sh \ --version 1.2.0 \ --base-url https://github.com/arach/hudsonkit-xcframework/releases/download/1.2.0 ``` ## What the script does - Stages each module in an isolated Swift package and compiles it as a dynamic library. Previously built Hudson dependencies are consumed as binary targets, ensuring that `HudsonUI` and `HudsonObservability` are linked rather than copied into `HudsonShell`. - Archives with `BUILD_LIBRARY_FOR_DISTRIBUTION=YES` so each framework contains stable `.swiftinterface` files. - Verifies every declared `@rpath` dependency and fails if a framework defines Swift symbols owned by one of its dependency modules. This prevents duplicate Objective-C runtime classes when an app loads the complete framework set. - Builds macOS `arm64` and `x86_64` slices by default. - Creates `.xcframework` bundles, zips them with the expected root layout, and computes `swift package compute-checksum` values. - Uses per-run DerivedData under `~/Library/Caches/codex-builds/` and deletes it unless `--keep-intermediates` is passed. ## Consumer package A public consumer package should reference only the hosted zips and checksums: ```swift // Package.swift // swift-tools-version: 5.9 import PackageDescription let package = Package( name: "Pomo", platforms: [.macOS(.v14)], dependencies: [ .package( url: "https://github.com/arach/hudsonkit-xcframework.git", exact: "1.2.0" ), ], targets: [ .executableTarget( name: "Pomo", dependencies: [ .product(name: "HudsonUI", package: "hudsonkit-xcframework"), .product(name: "HudsonShell", package: "hudsonkit-xcframework"), ] ), ] ) ``` Today `HudsonUI`'s public interface references `HudsonLive` and `HudsonObservability`, and `HudsonShell` references `HudsonUI` and `HudsonObservability`. The generated binary package includes those support modules as binary targets so downstream repos still contain zero Hudson source. --- # architecture > Source: https://hudsonkit.com/docs/architecture/ # Architecture Hudson is a monorepo with a web workspace, a web SDK, Apple-native Swift packages, and small runtime services. ## Monorepo layout ``` hudson/ apps/web/ # Next.js 16 app + marketing + deploy (App Router) app/ app/page.tsx # `/app` route — mounts layout.tsx # Root HTML + globals.css globals.css # Tailwind v4 + scrollbar styles apps/ # App implementations registry.ts # Aggregates built-in + local apps stage-design/ # Compact reference app theme-designer/ hudson-docs/ hudson-ai/ intent-explorer/ hud-logger/ services/ terminal/ workspaces/ hudsonOS.ts # Default workspace composition index.ts # Re-exports local/ apps.local.ts # Gitignored — dev-local app registrations workspaces.json lib/ # Shared utilities hooks/ # Shared hooks services/ # Service registry (hx, etc.) api/ # Next.js API routes (AI, saves, etc.) packages/ web/ hudsonkit/ # Web SDK (workspace-internal for now) src/ index.ts # Public main entry — types, hooks, AI, platform app-shell.ts # `hudsonkit/app-shell` subpath chrome.ts # `hudsonkit/chrome` overlays.ts # `hudsonkit/overlays` context-menu.ts # `hudsonkit/context-menu` canvas.ts # `hudsonkit/canvas` windows.ts # `hudsonkit/windows` theme.ts # `hudsonkit/theme` terminal.ts # `hudsonkit/terminal` shell.ts # `hudsonkit/shell` (back-compat barrel) workspace/shell/ # WorkspaceShell — multi-app orchestrator (`hudsonkit/workspace`) styles/bundle.css # Source for the compiled CSS bundle dist/styles.css # Compiled via `bun run build:css` components/ AppShell.tsx # Single-app shell (default) chrome/ # Frame, NavigationBar, SidePanel, StatusBar, CommandDock, Minimap, ZoomControls, AnimationTimeline canvas/Canvas.tsx windows/AppWindow.tsx overlays/ # CommandPalette, TerminalDrawer, HudsonContextMenu AI.tsx, TerminalRelay.tsx types/ app.ts # HudsonApp interface workspace.ts # HudsonWorkspace interface intent.ts, port.ts, service.ts hooks/ # usePersistentState, useAppSettings, useHudsonAI, useTerminalRelay platform/ # Platform adapter (web, desktop) lib/ # theme, sounds, logger, viewport, manifest create-hudson-app/ # App scaffold CLI native/ apple/ HudsonKit/ # Swift package for iOS/macOS shells services/ hudson-relay/ # WebSocket PTY relay service docs/ # Architecture, case study, builder notes ``` ## Data flow (WorkspaceShell) ``` apps/web/app/app/page.tsx └── │ ├── Nests every app's Provider recursively │ AppA.Provider → AppB.Provider → ... → WorkspaceShellInner │ └── WorkspaceShellInner ├── Calls each app's hooks (useCommands, useStatus, useSearch, ...) ├── Merges commands from all apps + shell commands → CommandPalette ├── Renders Frame with chrome (NavigationBar, SidePanel, StatusBar, CommandDock) ├── Canvas mode: wraps windowed apps in AppWindow, renders native apps directly on canvas ├── Panel mode: single focused app fills the viewport inset └── Merges app intents → IntentCatalog (for LLM/voice/search) ``` ## Data flow (AppShell) ``` app/page.tsx (consumer app — e.g. premotion, external project) └── │ └── app.Provider wraps everything └── AppShellInner reads app.hooks, fills slots ├── NavigationBar (title + search + nav center + nav actions) ├── SidePanel left (app.slots.LeftPanel + LeftFooter + CommandDock) ├── SidePanel right (app.slots.Inspector | RightPanel + tools accordion) ├── Content area (app.slots.Content) ├── StatusBar (app.hooks.useStatus + terminal toggle + clock) ├── TerminalDrawer (app.slots.Terminal or placeholder) └── CommandPalette (fed by useCommands + shell shortcuts) ``` ## Key architectural decisions ### Provider-first state Each app owns its state via a React context Provider. The shell never touches app internals — it only reads through the app's declared hooks. Apps stay fully decoupled. ### Hook Bridge pattern The shell calls an app's hooks *inside* that app's Provider scope via a small internal Bridge component. This means hooks can call `useMyAppContext()` safely. The shell doesn't import app modules — it only calls the hook functions registered in the `HudsonApp` object. ### Refs, not state, during drag/pan/resize Window bounds, pan/zoom offsets, and resize deltas are tracked in `useRef` during interaction — React re-renders are suppressed. State gets flushed on a debounce (`BOUNDS_FLUSH_MS = 500` in `WorkspaceShell.tsx`) so the minimap and persistence observe stable values without dragging the whole tree on every mouse move. See [`perf-drag-resize-patterns.md`](./perf-drag-resize-patterns.md) for the full set of tricks. ### Static intent declarations Intents are declared as plain data (not runtime functions) so they can be indexed, serialized, and searched without executing app logic. An intent executor bridges discovered intents back to the app's live commands at runtime. ### Recursive Provider nesting All app Providers wrap the entire workspace content. This enables cross-app context sharing when needed; isolated state is the default. ### Registry-driven app loading Hudson's own demo registers its built-in apps in `apps/web/app/apps/registry.ts` and merges in optional developer-local apps from a gitignored `apps/web/app/local/apps.local.ts` (auto-created as an empty stub on first run). The same split — committed shared registry + gitignored local override — works for any consumer that wants a stable default workspace alongside per-developer private apps. ## State persistence All persistent UI state uses `usePersistentState()` backed by localStorage: | Key pattern | Purpose | |------------------------------------------|---------------------------------------------------| | `hudson.ws.{workspaceId}.win.{appId}` | Window bounds (canvas mode) | | `hudson.ws.{workspaceId}.mode` | Current workspace mode | | `hudson.session` | Active session / focused app | | `appshell.{appId}.left` / `.right` | Side panel collapsed state (AppShell) | | `appshell.{appId}.leftW` / `.rightW` | Side panel widths (AppShell) | | `{appId}.{key}` | App-specific state (owned by app Provider) | App code should prefer the `{appId}.{key}` namespace and use `usePersistentState('myapp.notes', [])` — the hook handles serialization, SSR safety, and cross-tab sync. ## Build & dev | Command | Purpose | |---------------------------------------------|-------------------------------------------| | `bun install` | Install all workspace deps | | `bun dev` | Start Hudson dev server on :3500 | | `bun run build` | Production build | | `bun run lint` | ESLint | | `bun run relay` | Start the terminal relay WS server | | `cd packages/web/hudsonkit && bun run build:css` | Rebuild the SDK's compiled CSS bundle | --- # Building app AI > Source: https://hudsonkit.com/docs/building-app-ai/ Adding an AI surface to a Hudson app — the toolset + hook pattern # Building app AI This guide is for agents and authors adding an AI surface (chat composer + tool-driven actions) to a Hudson app. The pattern is **two files**: one server-side toolset, one client-side hook. Once you know the shape, copy the reference app that's closest to what you're building and edit. > Working examples to copy from, simplest first: > - **`apps/web/app/apps/hudson-ai/`** — model/provider settings, chat surface, and workspace-level AI affordances. > - **`apps/web/app/api/ai/toolsets/workspace.ts`** — shell/workspace tools, including service-backed actions. > - **`apps/web/app/api/ai/toolsets/intents.ts`** — intent-catalog tools that bridge AI to live app commands. ## Mental model The server defines **what** the AI can do (system prompt, tool schemas). The client defines **what to do with results** (apply state changes when a tool fires). The two halves meet at one string: the toolset id. ``` client server useFooAI(opts) POST /api/ai/chat route.ts ↓ context = { ... } ───────────────────→ ↓ useHudsonAI({ createPiAiBackend().streamUI({ toolset: 'foo', ... messages, toolset: 'foo', ... onToolCall: ... }) }) ↓ defaultRegistry.resolve('foo') ↓ { system, context(ctx), tools(ctx) } ↓ pi-ai stream → tool_call text-delta / ←────────────────────── tool-input-available ↓ onToolCall('set_x', { ... }) → app state changes ``` The route, the backend, the multi-step tool loop — none of that is your concern. You write a server toolset and a client hook. ## File layout For a new app `foo`: ``` apps/web/app/api/ai/toolsets/foo.ts ← server: system prompt, context renderer, tool schemas apps/web/app/apps/foo/useFooAI.ts ← client: useHudsonAI() wrapper + onToolCall router apps/web/app/api/ai/toolsets/index.ts ← add one line: defaultRegistry.register('foo', fooToolset) ``` Naming conventions: - Toolset id matches app id (`foo` ↔ `apps/web/app/apps/foo/`). - Hook is `useFooAI` (PascalCase app name). - Toolset export is `fooToolset` (lowercase, matches id). ## Server side — the toolset A `ToolsetDefinition` has three parts: `system`, `context(ctx)`, `tools(ctx)`. The shape is enforced by `import type { ToolsetDefinition } from '@hudsonkit/ai/toolsets'`. ```ts // apps/web/app/api/ai/toolsets/foo.ts import { tool } from 'ai'; import { z } from 'zod'; import type { ToolsetDefinition } from '@hudsonkit/ai/toolsets'; // ─── System prompt ────────────────────────────────────────────────────────── // Static. Personality, taste, capability docs. Doesn't change per request. const system = `You are a [role] working in [app]. [Voice / taste constraints.] ## How this works [How the app surfaces work + what tools do.] ## Design rules - Never use purple. Prefer cyan, teal, emerald. - [Other taste constraints from CLAUDE.md.] ## Response style - Brief. After tool calls, summarize what changed in 1-2 sentences. - Don't narrate. Don't apologize.`; // ─── Context renderer ─────────────────────────────────────────────────────── // Dynamic. Recomputed every request from whatever the client puts in `context`. function context(ctx: Record): string { const c = ctx as { items?: unknown[]; mode?: string }; const sections: string[] = []; if (c.mode) sections.push(`## Mode\n${c.mode}`); if (c.items?.length) { sections.push(`## Items (${c.items.length})\n\`\`\`json\n${JSON.stringify(c.items, null, 2)}\n\`\`\``); } return sections.join('\n\n'); } // ─── Tools ────────────────────────────────────────────────────────────────── // Zod-validated schemas. The `execute` callback runs server-side but is almost // always an echo — the real mutation happens client-side via `onToolCall`. function tools(_ctx: Record) { return { set_mode: tool({ description: 'Switch the app into a different mode.', inputSchema: z.object({ mode: z.enum(['draft', 'review', 'published']).describe('Target mode'), }), execute: async (args) => ({ applied: true, ...args }), }), add_item: tool({ description: 'Add a new item to the current list.', inputSchema: z.object({ title: z.string().describe('Display name'), priority: z.number().int().min(1).max(5).optional().describe('1=low, 5=high'), }), execute: async (args) => ({ applied: true, ...args }), }), }; } export const fooToolset: ToolsetDefinition = { system, context, tools }; ``` Register it once in `apps/web/app/api/ai/toolsets/index.ts`: ```ts import { fooToolset } from './foo'; defaultRegistry.register('foo', fooToolset); ``` ### Tool design rules - **Schemas with `.describe()` everywhere.** The model reads these. Don't skip them. - **Tool names are snake_case verbs**: `set_param`, `add_item`, `delete_template`. Never `setParam` or `Item.add`. - **One responsibility per tool.** If you find yourself writing `do_thing` with a switch inside, split it. - **`execute` is usually an echo, not the action.** Return `{ applied: true, ...args }` (or a small status object). The actual state change usually happens in the client's `onToolCall`. The exception is work that genuinely belongs server-side: service execution, database writes, compilation, or fetches. - **Use `z.enum` over `z.string`** when there's a fixed list. The model gets clearer guidance and you get validation. - **Optional params are optional.** Don't force the model to always pass everything. ### System prompt voice Match the existing apps. They follow this skeleton: ``` You are a [role specific to the app]. ## How this works [1-2 paragraphs explaining the app surface + what tools do.] ## [Domain rules — taste, parameters, constraints] [Bullets. Specific. Concrete examples.] ## Response style - [Brevity constraints] - [What to do after tool calls] ``` Hudson-global constraints to include in every prompt: - "Never use purple. Prefer cyan, teal, emerald." - "Less is more — remove before adding." - "Optical corrections over mathematical perfection." Don't make the model recite these — embed them in the taste section so they shape every decision. ## Client side — the hook The hook glues app state to `useHudsonAI`. Three things you control: `context`, `onToolCall`, and (optionally) `attachments`. ```ts // apps/web/app/apps/foo/useFooAI.ts 'use client'; import { useCallback, useMemo, useState, useEffect } from 'react'; import { useHudsonAI } from 'hudsonkit'; import type { AIAttachment, AppSettingsValues } from 'hudsonkit'; interface UseFooAIOptions { // App state the AI can read items: Item[]; mode: Mode; // App setters the AI can drive (via onToolCall) setMode: (mode: Mode) => void; addItem: (item: Item) => void; // App settings for provider/model selection appSettings: AppSettingsValues; } export interface AiActivityEntry { id: number; tool: string; summary: string; timestamp: number; } let _activityId = 0; export function useFooAI(opts: UseFooAIOptions) { const { items, mode, setMode, addItem, appSettings } = opts; // Activity log — surfaces "what did the AI just do" in the UI const [activity, setActivity] = useState([]); const logActivity = useCallback((tool: string, summary: string) => { setActivity(prev => [...prev.slice(-9), { id: ++_activityId, tool, summary, timestamp: Date.now() }]); }, []); // What the AI sees — sent with every request. Wrap in useMemo so the // transport's body() function reads stable references. const context = useMemo(() => ({ items, mode }), [items, mode]); // Opt-in extras the user can toggle from the chat composer const attachments: AIAttachment[] = useMemo(() => [ { label: 'Selection', content: () => { const el = document.querySelector('[data-selected]'); return el ? el.outerHTML : null; }, }, ], []); const chat = useHudsonAI({ toolset: 'foo', // ← matches server registration chatId: 'foo-app-chat', // ← stable id; persists across remounts context, attachments, provider: String(appSettings.aiProvider || 'copilot'), model: String(appSettings.aiModel || 'gemini-3-flash-preview'), onToolCall: async (name, args) => { try { switch (name) { case 'set_mode': setMode(args.mode as Mode); logActivity('set_mode', String(args.mode)); break; case 'add_item': addItem({ id: crypto.randomUUID(), title: args.title as string, ... }); logActivity('add_item', `Added "${args.title}"`); break; } } catch (err) { logActivity('error', `${name}: ${err instanceof Error ? err.message : String(err)}`); } }, }); // Surface chat errors in the activity log useEffect(() => { if (chat?.error) logActivity('error', String(chat.error).slice(0, 80)); }, [chat?.error, logActivity]); return { sendAiMessage: (message: string) => chat.sendMessage({ text: message }), aiStatus: chat?.status ?? 'ready', aiMessages: chat?.messages ?? [], aiActivity: activity, aiError: chat?.error ? String(chat.error) : null, aiChat: chat, // full handle for surfaces using }; } ``` ### Client patterns to follow - **`useMemo` on `context`.** It's sent on every request; identity churn = wasted re-renders. - **`attachments` ≠ `context`.** Attachments are user-toggled (chip in the composer). Use them for big payloads (current SVG, file dump) the user might NOT want sent every turn. - **`onToolCall` does the actual work, not `execute`.** Centralize app-state mutations here. - **Coerce values defensively.** Some models (notably MiniMax) send string-encoded numbers (`"20"` not `20`). Coerce inside `onToolCall` for numeric params. - **Log activity.** Users want to see "what did it just do". The 10-entry rolling log pattern (`prev.slice(-9)`) is the convention. - **Wrap `chat.sendMessage` in your own helper** if you want logging or pre/post hooks. ## Provider / model selection Default to whatever the app's settings say: ```ts provider: String(appSettings.aiProvider || 'copilot'), model: String(appSettings.aiModel || 'gemini-3-flash-preview'), ``` `appSettings.aiProvider` and `appSettings.aiModel` come from the app's declarative settings schema (see `docs/settings.md`). If your app doesn't expose these as user-tunable settings, hardcode sensible defaults — but pick `copilot` + a Gemini-flash model unless you have a specific reason. They're fastest and cheapest for tool-driven work. Available providers today (see `apps/web/app/api/ai/providers.ts`): - `copilot` (GitHub Copilot, OAuth via OpenCode auth.json) - `anthropic`, `openai`, `groq`, `xai`, `google`, `github`, `minimax` The backend handles credential resolution; you just pass the provider id. ## Verification After wiring the two files + the registration line: ```bash # 1. Type-check npx tsc --noEmit # 2. Tests (if you added any) npx vitest run # 3. Smoke the endpoint (dev server on 3500) curl -sS -X POST http://localhost:3500/api/ai/chat \ -H "Content-Type: application/json" \ -d '{"messages":[{"id":"u1","role":"user","parts":[{"type":"text","text":"hello"}]}],"toolset":"foo","context":{},"mode":"api"}' \ --max-time 30 | head -10 # Should see: data: {"type":"start"}, text-start, text-delta, [DONE] # 4. Trigger a tool call curl -sS -X POST http://localhost:3500/api/ai/chat \ -H "Content-Type: application/json" \ -d '{"messages":[{"id":"u1","role":"user","parts":[{"type":"text","text":"switch to review mode"}]}],"toolset":"foo","context":{"mode":"draft"},"mode":"api"}' \ --max-time 30 | grep -E "tool-input|text-delta" | head -5 ``` In the running app, open the AI composer and ask the model to do something covered by your tools. Watch the activity log update. ## Common pitfalls - **Forgot to register the toolset.** `loadToolset('foo', ctx)` returns `{tools: {}, system: undefined}` silently. Symptom: model responds chatty but never calls tools. - **Tool schemas without descriptions.** Model invokes tools but with wrong arg shapes. Add `.describe()` to every field. - **`context` not memoized.** Identity churn → request body churn. Symptom: weird state-mid-request bugs. - **Trying to read app state in the toolset's `tools(ctx)`.** `ctx` is the request's context object only. If you need app state, put it in `context` on the client side. - **Returning state from `execute`.** It echoes to the client as `tool-output-available`, but apps don't typically read that. Mutate via `onToolCall`. ## Future: declarative `ai: hudAI({...})` The current pattern requires a hook file + a toolset file + a registration line. The brief (HUD-006 step 5) envisions collapsing this into a single field on `HudsonApp`: ```ts // future import { hudAI } from 'hudsonkit'; export const fooApp: HudsonApp = { id: 'foo', name: 'Foo', ai: hudAI({ toolset: 'foo', backend: 'pi-ai' }), // ... }; ``` Until that lands, the two-file pattern above is the path. The convention is stable — the future migration will be mechanical. --- # building-apps > Source: https://hudsonkit.com/docs/building-apps/ # Building Apps A Hudson app is a plain TypeScript object satisfying the `HudsonApp` interface. The shell reads the object and renders chrome around it. This guide covers the whole contract with concrete examples. ## The interface ```ts import type { HudsonApp } from 'hudsonkit'; ``` ### Required ```ts interface HudsonApp { id: string; // unique identifier + localStorage namespace name: string; // display name (app switcher, window title) mode: 'canvas' | 'panel'; // default frame mode Provider: React.FC<{ children: ReactNode; disabled?: boolean; visible?: boolean; focused?: boolean }>; slots: { Content: React.FC; // main area — the only required slot // (all others optional) }; hooks: { useCommands: () => CommandOption[]; // Cmd+K palette useStatus: () => { label: string; color: StatusColor }; // status bar indicator // (all others optional) }; } ``` ### Optional panel configuration ```ts { description?: string; // tooltip / palette description leftPanel?: { title: string; icon?: ReactNode; headerActions?: React.FC; // rendered in the panel header }; rightPanel?: { title: string; icon?: ReactNode; headerActions?: React.FC }; } ``` ### Optional slots ```ts slots: { Content: React.FC; // required LeftPanel?: React.FC; // fills the left side panel Inspector?: React.FC; // fills the right side panel (preferred over RightPanel) RightPanel?: React.FC; // @deprecated — use Inspector + tools LeftFooter?: React.FC; // sits above the Cmd+K dock Terminal?: React.FC; // custom terminal drawer content Takeover?: React.FC; // full-viewport overlay above the shell } ``` ### Optional hooks ```ts hooks: { useCommands: () => CommandOption[]; useStatus: () => { label: string; color: StatusColor }; useSearch?: () => SearchConfig; // nav bar search useNavCenter?: () => ReactNode | null; // breadcrumb / context label useNavActions?: () => ReactNode | null; // nav bar right-side actions useLayoutMode?: () => 'canvas' | 'panel' | 'focus'; // override mode at runtime useActiveToolHint?: () => string | null; // highlights a tool in Inspector usePortOutput?: () => (portId: string) => unknown | null; usePortInput?: () => (portId: string, data: unknown) => void; useTakeover?: () => TakeoverState | null; // gate a full-viewport overlay } ``` ### Optional advanced fields ```ts { multiInstance?: 'singleton' | 'spawnable' | 'duplicable'; // how many live copies allowed (default: 'singleton') tools?: AppTool[]; // tool panels in the right sidebar accordion intents?: AppIntent[]; // static declarations for LLM/voice/search manifest?: AppManifest; // serializable capability snapshot settings?: AppSettingsConfig; // app-level settings schema (see settings.md) ports?: AppPorts; // input/output ports for inter-app data piping services?: ServiceDependency[]; // external process deps (via the hx registry) } ``` See [Systems](./systems.md) for intents, ports, and services. See [Multi-instance](./multi-instance.md) for `multiInstance`. See [Settings](./settings.md) for `settings`. ## Directory layout A typical app lives under `apps/web/app/apps//`: ``` apps/web/app/apps/my-app/ index.ts # HudsonApp export MyAppProvider.tsx # React context + state MyAppContent.tsx # Content slot MyAppLeftPanel.tsx # (optional) LeftPanel slot MyAppInspector.tsx # (optional) Inspector slot hooks.ts # useCommands, useStatus, etc. intents.ts # (optional) static intent declarations ports.ts # (optional) port hooks ``` ## Walkthrough: a counter app ### 1. Provider Owns state and exposes it via context: ```tsx // MyAppProvider.tsx 'use client'; import { createContext, useContext, useState, type ReactNode } from 'react'; import { usePersistentState } from 'hudsonkit'; interface CounterValue { count: number; increment: () => void; reset: () => void; } const CounterContext = createContext(null); export function useCounter() { const ctx = useContext(CounterContext); if (!ctx) throw new Error('useCounter must be inside CounterProvider'); return ctx; } export function CounterProvider({ children }: { children: ReactNode }) { const [count, setCount] = usePersistentState('counter.count', 0); const value: CounterValue = { count, increment: () => setCount(c => c + 1), reset: () => setCount(0), }; return {children}; } ``` `usePersistentState` is SSR-safe and cross-tab-synced — use it for anything you want to survive a refresh. ### 2. Content slot ```tsx // MyAppContent.tsx 'use client'; import { useCounter } from './MyAppProvider'; export function MyAppContent() { const { count, increment } = useCounter(); return (
{count}
); } ``` ### 3. Hooks ```ts // hooks.ts 'use client'; import { useMemo } from 'react'; import type { CommandOption, StatusColor } from 'hudsonkit'; import { useCounter } from './MyAppProvider'; export function useCounterCommands(): CommandOption[] { const { increment, reset } = useCounter(); return useMemo(() => [ { id: 'counter:increment', label: 'Increment', action: increment, shortcut: 'Cmd+I' }, { id: 'counter:reset', label: 'Reset', action: reset }, ], [increment, reset]); } export function useCounterStatus(): { label: string; color: StatusColor } { const { count } = useCounter(); return { label: `count: ${count}`, color: count > 0 ? 'emerald' : 'neutral' }; } ``` ### 4. Compose the `HudsonApp` ```ts // index.ts import { createElement } from 'react'; import { Hash } from 'hudsonkit/icons'; import type { HudsonApp } from 'hudsonkit'; import { CounterProvider } from './MyAppProvider'; import { MyAppContent } from './MyAppContent'; import { useCounterCommands, useCounterStatus } from './hooks'; export const counterApp: HudsonApp = { id: 'counter', name: 'Counter', description: 'A minimal example app', mode: 'panel', leftPanel: { title: 'Counter', icon: createElement(Hash, { size: 12 }) }, Provider: CounterProvider, slots: { Content: MyAppContent }, hooks: { useCommands: useCounterCommands, useStatus: useCounterStatus, }, }; ``` ## Provider lifecycle props The shell passes three optional booleans into every `Provider`: ```ts Provider: React.FC<{ children: ReactNode; disabled?: boolean; visible?: boolean; focused?: boolean }> ``` - **`disabled`** — the app is mounted but not participating in the workspace. Pause all background work. - **`visible`** — the app is on screen. Pause expensive work when `false`. - **`focused`** — the app is the active target for shell interactions. Reserve high-frequency polling or subscriptions for when this is `true`. **Important:** `AppShell` (single-app shell) only passes `children` to the Provider — it does not thread `disabled`, `visible`, or `focused`. These props are exercised inside `WorkspaceShell`, where multiple apps share screen real estate and exactly one is focused at a time. If you're building for `AppShell` only you can ignore them for now, but writing defensive code costs nothing. Pattern inside `CounterProvider`: ```tsx export function CounterProvider({ children, focused = true, }: { children: ReactNode; disabled?: boolean; visible?: boolean; focused?: boolean; }) { const [count, setCount] = usePersistentState('counter.count', 0); // Only run the auto-increment ticker when the app is focused. useEffect(() => { if (!focused) return; const id = setInterval(() => setCount(c => c + 1), 5000); return () => clearInterval(id); }, [focused, setCount]); const value = useMemo( () => ({ count, increment: () => setCount(c => c + 1), reset: () => setCount(0) }), [count, setCount], ); return {children}; } ``` Drop `disabled` and `visible` into the signature the same way if your Provider does network fetching or animation loops that should pause when the app is hidden. --- ## Tools (Inspector accordion) Declare `tools` on the `HudsonApp` object to add collapsible panels to the right sidebar accordion. Each entry satisfies `AppTool`: ```ts interface AppTool { id: string; name: string; icon: ReactNode; Component: React.FC; } ``` Tools render below the `Inspector` slot (or the deprecated `RightPanel` slot if `Inspector` is absent). The user opens and closes them independently; the shell persists nothing — open state resets on remount. Example — a **Layers** tool that reads from the counter context: ```tsx // tools.tsx 'use client'; import { createElement } from 'react'; import { Layers } from 'hudsonkit/icons'; import type { AppTool } from 'hudsonkit'; import { useCounter } from './MyAppProvider'; function LayersTool() { const { count } = useCounter(); return (
count {count}
); } export const counterTools: AppTool[] = [ { id: 'counter:layers', name: 'Layers', icon: createElement(Layers, { size: 12 }), Component: LayersTool, }, ]; ``` Add to the app object: ```ts import { counterTools } from './tools'; export const counterApp: HudsonApp = { // ... tools: counterTools, }; ``` Use `useActiveToolHint` to highlight a specific tool programmatically — return the tool's `id` from the hook and the shell applies an accent style to its header button. --- ## Takeover slot `slots.Takeover` is a full-viewport component rendered above all chrome. It activates when `hooks.useTakeover` returns `{ active: true }`. While active, the shell marks the rest of the UI `inert` and `aria-hidden` — pointer events and keyboard focus cannot reach chrome. The overlay is wrapped in a `role="dialog" aria-modal="true"` container; the shell moves focus into it automatically. `TakeoverState` shape: ```ts interface TakeoverState { active: boolean; dismissible: boolean; // shell renders a close affordance + handles Escape onDismiss?: () => void; // called by the shell's Escape / close button } ``` When `dismissible` is `true`, pressing Escape calls `onDismiss`. The hook owns the state; the shell is stateless about dismissal — flipping `active` to `false` in `onDismiss` is what clears the overlay. Typical pattern — first-run setup: ```tsx // MyAppProvider.tsx interface CounterValue { count: number; increment: () => void; reset: () => void; setupDone: boolean; completeSetup: () => void; } export function CounterProvider({ children }: { children: ReactNode }) { const [count, setCount] = usePersistentState('counter.count', 0); const [setupDone, setSetupDone] = usePersistentState('counter.setup', false); const value = useMemo(() => ({ count, increment: () => setCount(c => c + 1), reset: () => setCount(0), setupDone, completeSetup: () => setSetupDone(true), }), [count, setCount, setupDone, setSetupDone]); return {children}; } ``` ```ts // hooks.ts export function useTakeover(): TakeoverState | null { const { setupDone, completeSetup } = useCounter(); if (setupDone) return null; return { active: true, dismissible: false, onDismiss: completeSetup }; } ``` ```tsx // CounterSetup.tsx 'use client'; import { useCounter } from './MyAppProvider'; export function CounterSetup() { const { completeSetup } = useCounter(); return (

Welcome to Counter

); } ``` ```ts // index.ts import { useTakeover } from './hooks'; import { CounterSetup } from './CounterSetup'; export const counterApp: HudsonApp = { // ... slots: { Content: MyAppContent, Takeover: CounterSetup }, hooks: { useCommands: useCounterCommands, useStatus: useCounterStatus, useTakeover }, }; ``` --- ## Command palette behavior `useCommands` returns the list of entries that appear in the `Cmd+K` palette. Each `CommandOption`: ```ts interface CommandOption { id: string; // must be unique across all commands (app + shell) label: string; // display string; palette filters by substring match against this action: () => void; // called when triggered; palette closes immediately after shortcut?: string; // display hint only — NOT a registered key binding icon?: ReactNode; // optional icon in the palette row } ``` The shell merges your app's commands with its own built-in shell commands (toggle panels, toggle terminal, theme switching, etc.) before passing the combined list to the palette. Duplicate `id` values from your app will not shadow shell commands — keep IDs namespaced: `'counter:increment'`, not `'increment'`. **Shortcuts are display hints.** The `shortcut` string is rendered in the palette row as a visual cue. It does not register a global key listener. To make `Cmd+I` actually trigger `increment`, wire it yourself inside the Provider: ```tsx useEffect(() => { const handler = (e: KeyboardEvent) => { if ((e.metaKey || e.ctrlKey) && e.key === 'i') { e.preventDefault(); increment(); } }; window.addEventListener('keydown', handler); return () => window.removeEventListener('keydown', handler); }, [increment]); ``` Palette interaction flow: the user types → substring filter against `label` → `ArrowUp`/`ArrowDown` to navigate → `Enter` or click calls `action()` and closes. Memoize the array returned from `useCommands` — the shell calls the hook on every render. ## Registering the app ### Inside Hudson (default workspaces) Register committed apps in `apps/web/app/apps/registry.ts`. That file has three moving parts: 1. Import the app near the other in-tree apps. 2. Add it to the `getAppById()` lookup table so JSON/local workspace entries can resolve the app by id. 3. Add a `WorkspaceAppConfig` entry to one of the core workspace getters (`getCoreApps()`, `getDeveloperModeApps()`, `getDocumentLabWorkspace()`, etc.), or create a new `HudsonWorkspace` getter and include it in `getCoreWorkspaces()`. ```ts import { counterApp } from './counter'; function getAppById(id: string): HudsonApp | null { const table: Record = { // ...existing apps 'counter': counterApp, }; return table[id] ?? null; } function getCoreApps(): WorkspaceAppConfig[] { return [ // ...existing apps { app: counterApp, canvasMode: 'windowed', defaultWindowBounds: { x: 120, y: 120, w: 420, h: 320 }, }, ]; } ``` Committed apps appear where their workspace getter includes them. `allWorkspaces` combines those core workspaces with dev-only local workspace sources. ### Inside Hudson (dev-local only) For apps you don't want to commit, add to `apps/web/app/local/apps.local.ts` (gitignored; auto-created by `apps/web/next.config.ts`): ```ts import type { HudsonWorkspace, WorkspaceAppConfig } from 'hudsonkit'; import { counterApp } from '../apps/counter'; export const localApps: WorkspaceAppConfig[] = [ { app: counterApp, canvasMode: 'windowed' }, ]; export const localWorkspaces: HudsonWorkspace[] = []; ``` ### Outside Hudson (consumer app via `AppShell`) A fresh Next.js 16 + React 19 + Tailwind v4 app can consume the SDK and render a single app: ```tsx // app/page.tsx 'use client'; import { AppShell } from 'hudsonkit/app-shell'; import { counterApp } from '@/counter'; export default function Page() { return ; } ``` ```css /* app/globals.css */ @import "tailwindcss"; @import "hudsonkit/styles"; ``` ## Rules of thumb - **Always `'use client'`** on every file that imports from `hudsonkit` or uses hooks — the SDK components are client-side only, and the RSC boundary must be explicit. - **Provider goes first.** Slots and hooks read state from the Provider's context. The shell wraps everything in the Provider once; you never wrap it manually. - **`usePersistentState` over raw `useState`** for anything you want surviving a refresh (note selection, filter state, panel sizes, etc.). - **URL state is free.** If your app has filters, selected items, or views worth deep-linking, store state in query params via `useSearchParams` + `router.replace`. The Provider reads from the URL; browser back/forward just works. - **Keep hooks cheap.** The shell calls them on every render. Memoize command arrays, avoid building large objects on the fly. - **`useMemo` the context value.** Without it, every Provider render creates a new value reference and downstream consumers re-render for nothing. ## Further reading - [Systems](./systems.md) — Intents, Services, Ports - [Perf patterns](./perf-drag-resize-patterns.md) — drag/resize/pan optimizations used by the shell - [API reference](./api.md) — every `hudsonkit` export with a short description --- # Cache > Source: https://hudsonkit.com/docs/cache/ Shared cache primitive and policy direction for hudsonkit/cache # Cache ## Overview `hudsonkit/cache` is the shared substrate apps use instead of reaching for `Map`, `localStorage`, or a bespoke fetch wrapper each time they need to remember a value. It ships TTL + stale-while-revalidate semantics, in-flight async dedupe, tag invalidation, optional `local`/`session` persistence, hydrate/dehydrate, bounded entry counts, and a React hook. The primitive is intentionally small. The bigger idea is **caching as policy** — pick a named policy for the kind of data you're storing rather than tuning numbers per call site. That keeps app code declarative and lets us swap implementations (or graduate to TanStack Query for serious server state) without touching every call site. > **Status.** The substrate (`createHudsonCache`, `hudsonCache`, `useCachedResource`) ships today. The named-policy surface (`cachePolicies`, `cachedFetchJson`, `useHudsonQuery`, `createDerivedCache`, `createAssetCache`) is the direction described in `docs/specs/hud-010-cache-policies.md` and is being layered in. Use the substrate now; expect a policy import to land. ## What is cache, and what isn't Cache holds **derivable** values — things you can refetch, recompute, or rebuild if missing. Anything authoritative is **state**, not cache, and should live in a Provider, a reducer, `usePersistentState`, or `appStorage`. | Use cache for | Use state for | |---|---| | API responses you re-read | The user's selection / active tool | | Parsed/derived values from inputs | The active document | | Image or blob metadata | Unsaved edits | | Service status snapshots | Inspector settings persisted per user | If invalidating it would lose user work, it's not cache. ## Policy categories Cache decisions split cleanly by data type. Reach for the right tool per category — Hudson's primitive isn't always it. ### API data External fetches that need freshness, dedupe, retry, and invalidation. Use `useCachedResource` (or a `cachedFetchJson` helper, once it lands) with the `apiLive` or `apiCatalog` policy. If you need pagination, optimistic mutation, dependent queries, or devtools, **adopt TanStack Query** behind a thin Hudson adapter rather than expanding the substrate. _Examples:_ model catalog, service status, remote app manifests, workspace metadata, `/api/{app}/...` responses. ### Derived data Memoized computations from inputs already in memory. These want **bounded memory**, not persistence — use `maxEntries` and include an algorithm/version segment in the key so stale derived values don't survive an implementation change. _Examples:_ parsed markdown AST, computed shape bounds, rendered preview metadata, expensive search indexes, diff summaries. ### Asset / blob metadata Hudson's cache is great for the **metadata** ("we have a preview for asset X, last touched Y"); the bytes themselves belong in IndexedDB or the Cache API. Don't serialize images or data URLs through `localStorage` — you'll hit quota and slow down hydration. _Examples:_ generated images, data URLs, imported files, trace payloads, binary previews, rasterized SVG variants. ### Server route results Server-side computations cache through **Next.js cache APIs** (route tags, `revalidateTag`, `fetch` cache options) — not the client primitive. Align tag names with client cache tags where it helps mental model, but treat client and server invalidation as separate mechanisms. ### Offline / static resources HTTP responses, JS/CSS/HTML/image caching belongs to **Workbox / the Cache API / service workers**. Don't route those through the typed value cache. ### Provider state Not cache. App-owned mutable state lives in `Provider` + reducers/`usePersistentState` + `appStorage`. Don't hide write semantics behind cache invalidation. ## Key and tag conventions **Keys** identify exactly one entry. **Tags** group entries for batch invalidation. Don't use a tag as a key, and don't use a key as a tag. Prefer tuple-style conceptual keys (serialize with a small helper): ```ts ['api', appId, resource, id] ['asset', assetId, variant] ['derived', appId, algorithmVersion, inputHash] ['workspace', workspaceId, thing] ``` Tags should be broad enough to invalidate meaningful groups: ``` app:${appId} api:${resource} asset:${assetId} workspace:${workspaceId} ``` When the user signs out, blow away `app:${appId}`. When a model catalog refreshes, invalidate `api:models`. Don't reach for individual `delete()` calls when a tag fits. ## Policies (direction) Apps pick a named policy instead of hand-tuning durations. The exact numbers are defaults, not doctrine — the name is the contract. ```ts // shape we're moving toward — see docs/specs/hud-010-cache-policies.md export const cachePolicies = { apiLive: { ttlMs: 10_000, staleWhileRevalidateMs: 60_000, storage: null }, apiCatalog: { ttlMs: 5 * 60_000, staleWhileRevalidateMs: 60 * 60_000, storage: 'session' }, derived: { ttlMs: null, staleWhileRevalidateMs: null, storage: null, maxEntries: 500 }, session: { ttlMs: 30 * 60_000, staleWhileRevalidateMs: 30 * 60_000, storage: 'session' }, assetMetadata: { ttlMs: 24 * 60 * 60_000, staleWhileRevalidateMs: 24 * 60 * 60_000, storage: 'local' }, } as const; ``` Picking a policy is the call. Tuning the numbers happens in one place. ## Using the substrate today ### Imperative ```ts import { hudsonCache, createHudsonCache } from 'hudsonkit/cache'; // Shared default instance (namespace: 'hudson') const models = await hudsonCache.getOrLoad( 'api:hudson-ai:models', () => fetch('/api/ai/models').then(r => r.json()), { ttlMs: 5 * 60_000, staleWhileRevalidateMs: 60 * 60_000, tags: ['api:models'] }, ); // Invalidate a group hudsonCache.invalidateTag('api:models'); // Or your own scoped instance const previews = createHudsonCache({ namespace: 'shaper.preview', defaultTtlMs: null, maxEntries: 250, }); ``` ### React ```ts import { useCachedResource } from 'hudsonkit/cache'; const { data, status, isStale, refresh } = useCachedResource( 'api:hudson-ai:models', () => fetch('/api/ai/models').then(r => r.json()), { ttlMs: 5 * 60_000, staleWhileRevalidateMs: 60 * 60_000, tags: ['api:models'] }, ); ``` `useCachedResource` subscribes to the cache, so any other code path that writes, deletes, or invalidates this key will re-render consumers automatically. ## API at a glance Imported from `hudsonkit/cache`: | Export | Kind | Purpose | |---|---|---| | `createHudsonCache(options?)` | factory | Build a scoped cache (namespace, default TTL/SWR, `maxEntries`, optional `'local'`/`'session'` storage) | | `hudsonCache` | instance | Pre-built default cache (`namespace: 'hudson'`, memory-only) | | `useCachedResource(key, loader, options?)` | hook | Subscribed read with `data` / `status` / `isStale` / `refresh` / `invalidate` | Each cache exposes `read` / `get` / `has` / `set` / `getOrLoad` / `revalidate` / `delete` / `clear` / `invalidateTag` / `keys` / `prune` / `dehydrate` / `hydrate` / `subscribe`. Types (also exported): `HudsonCache`, `HudsonCacheEntry`, `HudsonCacheRead`, `HudsonCacheEvent`, `HudsonCacheStatus`, `HudsonCacheStorage`, `HudsonCacheLoadOptions`, `HudsonCacheSetOptions`, `HudsonCacheOptions`, `HudsonCacheLoader`, `CachedResourceStatus`, `UseCachedResourceOptions`, `UseCachedResourceResult`. ## When to reach past Hudson's primitive | Need | Reach for | |---|---| | Pagination, optimistic mutation, dependent queries, devtools | **TanStack Query** (or **SWR**) behind a Hudson adapter | | Bounded in-memory LRU for derived computations | The substrate with `maxEntries`, or `lru-cache` / `quick-lru` if you need true LRU eviction | | Storing megabytes (images, blobs, traces) | **IndexedDB** (via Dexie or raw) or the **Cache API** — Hudson cache holds the metadata only | | HTTP response / offline-first asset caching | **Workbox** / service worker / Cache API | | Server route + fetch caching | **Next.js cache APIs** — `revalidateTag`, route segment config | The rule of thumb: if the cache primitive starts growing pagination, mutation, or background refetch policy, stop and adopt an existing framework behind a Hudson facade rather than rebuilding it. ## Non-goals - Rebuilding TanStack Query. - Making cache the source of truth for app state. - Storing blobs or large data URLs in `localStorage`. - Cross-device sync or multi-user consistency. - A distributed Redis-like cache in v1. - Hiding write semantics behind automatic cache mutation. ## See also - `docs/specs/hud-010-cache-policies.md` — the in-flight spec this doc tracks. - [Vault](./vault.md) — for secrets, not derivable cache values. - [Settings](./settings.md) — for user-owned persistent state. --- # Relay: Terminal Server > Source: https://hudsonkit.com/docs/cli/relay/ Bridge WebSocket connections from the browser to PTY sessions on the host. # Relay -- Terminal Server `@hudson/relay` is a standalone server that bridges WebSocket connections from the browser to PTY sessions on the host machine. It powers the embedded terminal experience in Hudson apps via the `useTerminalRelay` hook (see [API Reference](../api.md)). ## What It Does The relay server: 1. Accepts WebSocket connections from Hudson apps running in the browser. 2. Spawns PTY processes (currently Claude CLI sessions) on the host. 3. Streams terminal I/O between the WebSocket and the PTY in real time. 4. Supports session persistence -- disconnected sessions stay alive for reconnection. 5. Exposes HTTP endpoints for TypeScript compilation and file uploads. ## Installation The relay is a private package within the Hudson monorepo. It requires native dependencies (`node-pty`, `ws`). ```bash cd packages/services/hudson-relay bun install ``` ### Dependencies | Package | Purpose | |---------|---------| | `node-pty` | Spawn and manage pseudo-terminal processes | | `ws` | WebSocket server | | `esbuild` | TypeScript-to-JavaScript compilation for the `/api/compile` endpoint | ## Starting the Server ```bash # Default port (3600) bun run relay # Custom port bun run relay -- --port 4000 # Via node directly node --no-warnings --import tsx packages/services/hudson-relay/src/index.ts --port 3600 ``` The server listens on a single port for both HTTP and WebSocket traffic. ### Environment Variables | Variable | Default | Description | |----------|---------|-------------| | `RELAY_PORT` | `3600` | Listen port. Overridden by `--port` flag. | | `HUDSON_RELAY_HOST` | `127.0.0.1` | Listen host. Non-loopback hosts require `HUDSON_RELAY_TOKEN`. | | `HUDSON_RELAY_TOKEN` | None | Shared bearer token required by non-health HTTP routes and WebSocket clients. Required for non-loopback binding. | | `HUDSON_RELAY_ALLOWED_ORIGINS` | None | Comma-separated additional browser origins. Loopback origins are allowed by default. | | `HUDSON_RELAY_UNSAFE_ALLOW_UNAUTHENTICATED_NON_LOOPBACK` | None | Set to exactly `1` only to deliberately allow an unauthenticated non-loopback bind. This exposes shell access to the network. | | `CLAUDE_BIN` | Auto-detected via `which claude` | Path to the Claude CLI binary. | Loopback development remains unauthenticated by default. To expose the relay on a network interface, configure a token: ```bash HUDSON_RELAY_HOST=0.0.0.0 HUDSON_RELAY_TOKEN='replace-with-a-strong-secret' bun run relay ``` Raw clients may omit the `Origin` header, but they must still send the configured token with `Authorization: Bearer `, `X-Relay-Token`, or the `token` query parameter. Browser origins must also be loopback or included in `HUDSON_RELAY_ALLOWED_ORIGINS`. ## Connecting from an App Use the `useTerminalRelay` hook from `hudsonkit` to connect to the relay from a React component. The hook manages the WebSocket lifecycle, session init, reconnection, and data streaming. ```tsx import { useTerminalRelay } from 'hudsonkit'; function MyTerminal() { const relay = useTerminalRelay({ url: 'ws://localhost:3600', cwd: '/Users/me/project', systemPrompt: 'You are a helpful coding assistant.', autoConnect: true, }); // relay.status, relay.onData, relay.sendInput, etc. } ``` See [API Reference -- useTerminalRelay](../api.md) for the full API. ## WebSocket Protocol All messages are JSON-encoded strings. The client sends `ClientMessage` types and the server responds with server messages. ### Client Messages #### session:init Sent once after the WebSocket opens. Creates a new PTY session. ```typescript interface SessionInitMessage { type: 'session:init'; cols: number; rows: number; systemPrompt?: string; cwd?: string; workspaceFiles?: Record; } ``` | Field | Required | Description | |-------|----------|-------------| | `cols` | Yes | Terminal width in columns. Minimum 20. | | `rows` | Yes | Terminal height in rows. Minimum 4. | | `systemPrompt` | No | System prompt passed to the CLI session. | | `cwd` | No | Working directory for the PTY. Defaults to `$HOME`. Supports `~` expansion. | | `workspaceFiles` | No | Map of relative paths to file contents. Files are created in the CWD if they do not already exist. Useful for bootstrapping project scaffolding before the CLI starts. | #### session:reconnect Resumes an existing session on a new WebSocket connection. ```typescript interface SessionReconnectMessage { type: 'session:reconnect'; sessionId: string; cols?: number; rows?: number; } ``` The server replays buffered output (up to 512 KB) so the terminal UI rebuilds its state. If the session has expired, the server responds with `session:expired`. #### terminal:input Sends raw keystrokes to the PTY. ```typescript interface TerminalInputMessage { type: 'terminal:input'; data: string; } ``` #### terminal:resize Resizes the remote terminal. ```typescript interface TerminalResizeMessage { type: 'terminal:resize'; cols: number; rows: number; } ``` ### Server Messages #### session:ready Sent after a successful `session:init` or `session:reconnect`. ```json { "type": "session:ready", "sessionId": "a1b2c3d4" } ``` On reconnect, includes `"reconnected": true`. #### session:error Sent when session creation fails (e.g., Claude CLI not found). ```json { "type": "session:error", "error": "Claude CLI not found. Install it with: npm install -g @anthropic-ai/claude-code" } ``` #### session:expired Sent in response to `session:reconnect` when the session no longer exists. ```json { "type": "session:expired", "sessionId": "a1b2c3d4" } ``` #### session:exit Sent when the PTY process exits. ```json { "type": "session:exit", "exitCode": 0 } ``` If the process crashed (non-zero exit within 5 seconds of start), includes a `reason` field with cleaned-up output from the PTY buffer. #### session:detached Sent to a previously-attached WebSocket when another client reconnects to the same session. ```json { "type": "session:detached" } ``` #### terminal:data Streams raw terminal output from the PTY. ```json { "type": "terminal:data", "data": "$ " } ``` ## Session Lifecycle ``` Client Server | | |-- WebSocket connect ------------->| |-- session:init { cols, rows } --->| spawn PTY |<-- session:ready { sessionId } ---| | | |<-- terminal:data { data } --------| (continuous) |-- terminal:input { data } ------->| |-- terminal:resize { cols, rows }->| | | |-- WebSocket close --------------->| session becomes orphaned | | (kept alive for 5 minutes) | | |-- WebSocket connect ------------->| |-- session:reconnect { id } ------>| reattach |<-- session:ready { reconnected }--| replay buffered output |<-- terminal:data { buffer } ------| ``` ### Orphaned Sessions When a WebSocket disconnects, the PTY session is not killed immediately. It enters an orphaned state and lives for 5 minutes, waiting for a reconnect. This handles browser tab refreshes and transient network issues gracefully. After 5 minutes without reconnection, the session is destroyed and the PTY process is killed. ### Output Buffering The server maintains a rolling output buffer of up to 512 KB per session. On reconnect, the entire buffer is replayed so the terminal UI (typically xterm.js) can rebuild the screen state. ## HTTP Endpoints The relay also serves HTTP endpoints on the same port. ### GET /health Liveness check. ```json { "ok": true } ``` ### POST /api/compile Compiles TypeScript source to JavaScript using esbuild. Used by the logo designer for live template compilation. Request: ```json { "source": "const x: number = 42; return String(x);" } ``` Response: ```json { "js": "const x = 42;\nreturn String(x);\n" } ``` Returns `422` if the source fails to compile or the compiled output does not produce a valid function. ### POST /api/upload Saves a base64-encoded file to `/tmp/hudson-uploads/`. Request: ```json { "name": "logo.png", "data": "iVBORw0KGgo..." } ``` Response: ```json { "path": "/tmp/hudson-uploads/abc123-logo.png" } ``` ## Configuration The relay has minimal configuration, controlled by CLI flags and environment variables: | Setting | Flag | Env Var | Default | |---------|------|---------|---------| | Listen port | `--port` | `RELAY_PORT` | `3600` | | Claude binary | -- | `CLAUDE_BIN` | Auto-detected | | Terminal type | -- | -- | `xterm-256color` | | Orphan timeout | -- | -- | 5 minutes | | Buffer size | -- | -- | 512 KB | ## Graceful Shutdown On `SIGINT` or `SIGTERM`, the server destroys all active PTY sessions, closes the WebSocket server, and shuts down the HTTP server cleanly. --- # Controls > Source: https://hudsonkit.com/docs/controls/ Parameter controls and code components for inspectors # Controls ## Overview `hudsonkit/controls` provides two categories of components for building inspector and settings panels: a suite of typed parameter controls (`ParamSlider`, `ParamToggle`, `ParamColor`, `ParamEnum`, `ParamText`, `ParamRepeatable`, `ParamGrid`) and text/code surfaces (`CodeViewer`, `CodeEditor`, `TextDocumentSurface`, `TextDiffSurface`). All components are styled to match the active Hudson theme and template via the shared token surface. `hudsonkit/controls` is a client entry. In React Server Component apps, import these controls from a Client Component boundary. ### Optional editor peers The heavier editor/markdown/diff runtimes are BYO optional peers and are loaded dynamically only by the components that need them: - `CodeEditor`: `@codemirror/*`, `@lezer/highlight` - `TextDocumentSurface` markdown preview: `react-markdown`, `remark-gfm` - `TextDiffSurface`: `@pierre/diffs` Install those packages in the consuming app when you use the corresponding control. Param controls and `CodeViewer` do not need them. ## Param controls ### ParamSection Collapsible labeled group. Wrap related controls to let users expand or collapse a category. | name | type | required | description | |---|---|---|---| | `label` | `string` | yes | Section heading text (rendered uppercase) | | `defaultExpanded` | `boolean` | no | Initial expanded state | | `children` | `ReactNode` | yes | Control components to render inside | ```tsx 'use client'; import { useState } from 'react'; import { ParamSection, ParamSlider } from 'hudsonkit/controls'; export function TypographyInspector() { const [size, setSize] = useState(16); return ( ); } ``` --- ### ParamSlider Numeric range input with a live value readout. | name | type | required | description | |---|---|---|---| | `label` | `string` | yes | Field label | | `value` | `number` | yes | Current value | | `min` | `number` | yes | Minimum value | | `max` | `number` | yes | Maximum value | | `step` | `number` | yes | Step increment | | `onChange` | `(v: number) => void` | yes | Called with the new value | | `format` | `(v: number) => string` | no | Custom display formatter | ```tsx import { useState } from 'react'; import { ParamSlider } from 'hudsonkit/controls'; export function OpacityControl() { const [opacity, setOpacity] = useState(1); return ( `${Math.round(v * 100)}%`} /> ); } ``` --- ### ParamToggle Boolean switch with label on the left and a pill toggle on the right. | name | type | required | description | |---|---|---|---| | `label` | `string` | yes | Field label | | `value` | `boolean` | yes | Current state | | `onChange` | `(v: boolean) => void` | yes | Called with the new state | ```tsx import { useState } from 'react'; import { ParamToggle } from 'hudsonkit/controls'; export function VisibilityToggle() { const [visible, setVisible] = useState(true); return ; } ``` --- ### ParamColor Hex color picker with a swatch and a hex readout. When `value` starts with `rgba`, the picker is suppressed and the raw string is displayed. | name | type | required | description | |---|---|---|---| | `label` | `string` | yes | Field label | | `value` | `string` | yes | Hex or rgba color string | | `onChange` | `(v: string) => void` | yes | Called with the new color string | ```tsx import { useState } from 'react'; import { ParamColor } from 'hudsonkit/controls'; export function FillControl() { const [color, setColor] = useState('#6366f1'); return ; } ``` --- ### ParamEnum `