Workspace Layout

The repository is a pnpm workspace with three packages. Dependencies point in one direction only: both apps depend on the protocol, never on each other.

agent-marketplace/
├── shared/    @market/shared   the protocol (zod only)
├── server/    @market/server   Hono + Drizzle + SQLite
├── client/    @market/client   Vue 3 SPA
└── pnpm-workspace.yaml
Package Role Key dependencies
@market/shared Message protocol: Agent, Worker, schemas, crypto, id generation, pub/sub zod — nothing else
@market/server HTTP server, services, rules engine hono, drizzle-orm, better-sqlite3, yaml, modern-errors
@market/client Single-page app vue, pinia, @tanstack/vue-query, vue-router, idb-keyval

The Stack, Layer by Layer

Layer Choice Why
Language TypeScript 5 (strict, ESM) End-to-end types — the same Message type on both sides of the wire
Runtime Node.js ≥ 20 Native Web Crypto (crypto.subtle), so the protocol’s crypto runs in Node and the browser
HTTP Hono 4 Tiny, fast, standards-based — the API surface is one route, so a micro-framework fits
Validation Zod Schemas double as runtime validators and inferred types
ORM Drizzle 0.41 Typed SQL over SQLite, migrations via drizzle-kit
Database SQLite (better-sqlite3) Local-first; swappable for Postgres by changing the Drizzle dialect
Frontend Vue 3.5 + Vite 6 Composition API, lazy routes, fast dev loop
Client state Pinia 2 Session state (auth flag, role, username)
Server cache TanStack Vue Query 5 Query caching + invalidation, kept separate from client state
Persistence idb-keyval IndexedDB store for the agent id and auth token
Testing Vitest 3 Same runner for protocol and server suites

The Dependency Inversion

The most deliberate piece of engineering: the protocol package must work on both sides of the wire, but a naive Agent.process needs the database (to verify identities and check revocations) — and a database dependency would leak into the client bundle.

The fix is dependency inversion. The agent takes injected hooks instead of importing anything:

// server/src/agent.ts — the HOST injects policy into the protocol
export const agent = new Agent({
  operations,
  encryptionKey,                            // server only
  permissions: { BUYER, SELLER, ADMIN: ["*"] }, // injected policy
  publicOperations: ["accounts/connect", "accounts/register"],
  isTokenRevoked,                           // server only, DB-backed
})
// client/src/application/agent.ts — the client injects nothing
const id = store.ensure("agent", genIdx())
const hub = new URL("/io", location.origin).toString()

export const agent = createAgent({ id, hub, operations: {} })

The result: @market/shared is genuinely storage- and domain-agnostic, the client bundle contains zero server or database code (verifiable in the build output), and authorization policy is owned by the server — not baked into the protocol.