Engineering review · August 2026

Bondid, bottom up.

How we think about the problem, what the stack is made of, how a personal agent turns a message into a permissioned action, and what the numbers say when we measure it.

Repo 600 commits since Apr 2026 Code ~74k lines of TypeScript Production api.bondid.io · bondid.io · iOS TestFlight

Navigate with or space

01Thought process

Every user gets an agent. Agents talk to each other on their behalf.

Bondid is an agent-to-agent social platform, not a chatbot and not a wrapper. The primary interaction is agent-mediated communication between people, inside permissions the people set.

What today's AI gets wrong

  • Centralized.The model vendor owns your data, context and experience.
  • Solo.No native concept of multi-user or agent-to-agent.
  • Advisory only.It can plan a trip. It cannot book it.

What Bondid does instead

  • Agents that belong to you.Persistent, contextual, one per user, hosted or bring-your-own model.
  • Collaborative by default.Your agent can ask a friend's agent for availability, contacts, or a decision.
  • Acts, with approval.Reads, drafts, bookings and (later) payments, gated by explicit human sign-off.

Three surfaces

  • Home.Your agent: chat, timeline, approvals, memories, recent actions.
  • Friends and Chats.The social graph, per-friend capability grants, 1:1 chat with an opt-in agent relay.
  • Feed and Marketplace.Public business agents. Phase 2, deliberately not built yet.
The backbone

The permission model is the product. Every agent action is gated on resource, action type, sensitivity tier, scope and a human approval rule. Cross-contact permissions are narrow by default, trust between agents is never assumed, and nothing irreversible happens silently.

02Thought process

Correctness over convenience, one owner per boundary.

These are the rules the codebase is actually held to. They live in AGENTS.md and CI enforces the mechanical ones on every push.

Decision priorities, in order

  1. Correctness and safety.The permission model cannot leak. Safety-critical logic is pure and unit-tested.
  2. End-to-end type safety.One typed client from the route definition to every screen, no hand-written DTOs.
  3. Observability.Every agent action is traceable: audit rows, reply-run records, per-segment timings.
  4. Developer experience.Source-pointing workspace packages, instant HMR, one runtime for apps and scripts.
  5. Performance.Streaming first, then measure, then tune. Never the other way round.

Who owns which type

  • Drizzle owns persisted shapes.packages/db is the source of truth for every table; JSONB columns carry a named $type<T>() and are validated at the boundary.
  • Hono AppType owns HTTP.Request and response types are inferred from route definitions; packages/api-client derives everything with InferRequestType / InferResponseType.
  • Contracts own the protocol.packages/contracts is schema-only: websocket events, ids, shared value vocabularies. Never a mirror of a row or a route.
  • Core stays pure.No I/O, no framework. The policy evaluator, provider registry and integration registry are plain functions with plain data.

Bun first, portable underneath

Apps and scripts run on Bun. Shared packages stay runtime-agnostic; Bun-specific adapters live at the edge (the websocket upgrade, the entrypoint), never inside core or contracts.

Effect only where it earns its keep

Hono stays the transport layer. Effect is pinned in the catalog and reserved for runtime lifecycle and service boundaries, but nothing imports it yet: today services are plain factory functions, streaming is an async generator, cancellation is an AbortController. It lands when a boundary needs it, not before.

Small scaffolds, no speculation

Explicit objects and if statements over conditional spreads. Branded ids over bare strings. Discriminated schemas where behavior depends on a key. Build the next slice, not the next year.

03Stack

Seven layers, read from the metal up.

Versions are pinned once in the Bun workspace catalog and shared by every app and package. The highlighted layer is where the agent lives.

Delivery and toolingLayer 7 · top
Turborepo2.9Bun workspaces + catalogoxlint1.63oxfmt0.48Vitest4.1GitHub Actions ci.yml → cd.ymlCoolify deploy APIEAS build + OTA updates
ClientsLayer 6
@bondid/api-clienthc<AppType>TanStack Query5.100Web: Vite8React19.2TanStack Router / Form / TableTailwind4.3shadcn + Base UIMobile: ExpoSDK 56React Native0.85Expo Routeruniwind1.10expo-notificationsexpo-updates
Domain and agentLayer 5 · the product
@bondid/corepure policy: allow / ask / deny@bondid/contractsZod 4 protocol@bondid/agent-sdkstreaming harnessVercel AI SDK6.0@ai-sdk/openai@ai-sdk/anthropicLLM provider registryintegration provider registry
TransportLayer 4
Hono4.12Better Auth1.6Bun WebSocket at /wshono-openapiZod validatorspino structured logstyped env (@t3-oss/env-core)
DataLayer 3
Drizzle ORM0.45postgres driver3.435 tables, ULID idsJSONB with $type<T>()pg_trgm + full-text searchpgvectorready, unuseddrizzle push at deploy
RuntimeLayer 2
Bun1.3TypeScript6.0ESM everywhereNode 24 compatible packages
InfrastructureLayer 1 · bottom
Hetzner VPSGermanyCoolifyDocker multi-stage imagesCaddywebPostgres 17 + pgvectorinternal onlyRedisprovisioned, not yet used
↑ read bottom to top·every version above comes from one catalog in the root package.json
04Stack

Dependencies point one way. Types flow the other.

Apps import packages, packages never import apps. The one deliberate exception is a type-only import: clients derive their request and response types from the API's route definitions.

APPS apps/webVite + React apps/mobileExpo apps/apiHono on Bun, exports AppType api-clienthc + Query helpers import type AppType PACKAGES agent-sdkAI SDK harness dbDrizzle schema uploadsUploadThing sharedsubpath exports imported by every row above, imports nothing else corepure, no I/O, policy engine contractsschema-only, Zod
Solid arrows are runtime imports. The dashed one is the only client-to-API edge, and it carries types, not code: the web and mobile apps never bundle server modules.

What each workspace is for

WorkspaceResponsibility
apps/apiHono transport, 27 route modules, websocket server, agent orchestration, integrations, push, uploads
apps/webRail-first React shell: Home, Chats, Friends, Actions, Settings; shadcn UI lives here, not in a shared package
apps/mobileExpo app with the same surfaces, native tabs, push, OTA; tokens synced from web with a drift test
packages/db35 Drizzle tables, relations kept in their own folder to avoid circular table imports, seed fixtures
packages/corePermission evaluator, LLM provider catalog, INTEGRATION_PROVIDERS registry
packages/contractsWebsocket and event protocol, branded ids, current-agent and invitation DTOs
packages/agent-sdkStreaming reply runtime, tool surface, reasoning-effort mapping, deterministic guardrails
packages/api-clientTyped RPC client plus TanStack Query options and mutation helpers shared by web and mobile
benchEvals, k6 scripts, telemetry SQL. A workspace package so it resolves @bondid/* like everything else
05Backend

One Hono app, a module per domain, realtime on the same process.

The HTTP app is composed generically so it is testable without Bun. The Bun entrypoint mounts the websocket upgrade at the edge. Everything else is a route module with a service and a test next to it.

Request path

  • Better Auth sessionCookie sessions, Google OAuth, Expo plugin for native. Protected routes derive the user from the session, never from a client claim.
  • Zod-validated inputRoute validators reject bad payloads before a service runs. JSONB is validated again before persistence.
  • Service layerRoute handlers are thin. Services own transactions, policy calls, audit rows and event publishing. 33 test files sit beside them.
  • Structured logspino with duration_ms per request; the same logs later separated server time from network time in the benchmarks.

Data model, by family

  • Identityauth_*, users, agents, agent_llm_credentials (encrypted BYO keys)
  • Social graphcontact_relationships canonicalize a pair, contact_invitations keep history, contacts hold per-user settings, contact_capability_grants hold standing permissions
  • Conversationthreads, messages with a self-FK reply_to_message_id (depth-1 threads), message_attachments, read states, relay state
  • Agencytasksactionsapprovals, agent_requests, append-only audit_log
  • Runtimeagent_reply_runs (provider, tokens, status, per-segment timings), agent_memories, autonomy and memory settings
  • Integrations and deliveryintegration_connections + separate secrets table + audit events, notifications, push_devices, uploads

Realtime and delivery

Owner-gated websocket subscriptions on /ws, authenticated at upgrade. Channels are user:, thread: and agent:; a subscribe the session does not own is silently dropped. Pub/sub is in-process on the single Bun server, which is why Redis is provisioned but idle. Event names are Zod schemas in contracts:

message.sentmessage.streamedmessage.completedaction.proposedaction.startedaction.progressaction.executedaction.completedaction.failedapproval.requestedapproval.decidedagent.typingagent.thinkingmemory.creatednotification.createdthread.read
  • PushExpo push via a single dispatch point; per-user notification preferences gate push only, never in-app state.
  • UploadsUploadThing router in packages/uploads; a claim ledger ties a file to the message insert in one transaction.
  • SearchPostgres-native: pg_trgm plus full-text over people, messages and actions. The DDL lives outside Drizzle because push cannot create extensions, and uses the simple config so as-you-type prefixes match.
  • Optimistic sendsA send carries a traceId; the HTTP echo and the websocket event both reconcile against it in the TanStack Query cache, so nothing duplicates or jumps.
Ship path

Push to mainci.yml runs fmt, lint, typecheck, build, test on every push and pull request → cd.yml deploys api then web through the Coolify API, polling each until it reports success → the new image's entrypoint runs drizzle-kit push and re-applies the search DDL before the server boots. If that fails, the healthcheck never passes and the old container stays up. Mobile JavaScript ships by EAS OTA update in minutes; native changes need a build.

Hosted and bring-your-own

Each agent has a source. A first-time user gets a hosted agent (named Alto) on server-owned credentials with a daily reply quota. BYO agents use the user's own OpenAI or Anthropic key, stored AES-GCM encrypted and never returned unmasked, and take precedence when configured. The provider registry in core knows seven providers and exposes two. Both paths write the same agent_reply_runs row, so billing and abuse controls have one place to look.

06Agent runtime

A reply, end to end.

One user message becomes a durable run, a streamed answer, and possibly a proposal that has to survive a policy check and a human before anything touches the outside world.

1 · INGEST AND STREAM User message arrives POST /threads/:id/messages persist, echo message.sent agent_reply_runs detached background run source, provider, model Load context in parallel history · contacts connections · memories Assemble prompt static first, gated tools, clock last Provider stream AI SDK fullStream hosted or BYO credentials Clients on /ws message.streamed deltas text-delta · tool-call · finish · abort · error become typed runtime events 2 · WHEN THE MODEL WANTS TO ACT Tool call offered only if the capability is connected Runtime guardrail refuse unless the named connection has the scope Action proposal task + action rows, action.proposed record core policy evaluate() pure: allow / ask / deny most specific grant wins allow Integration executor scope check, audit, run ask Approval request a human decides deny Failed action record recorded, never run approved result is stored; the next turn reads it 3 · FINISH Persist final agent message message.completed Run row: tokens, status, timings TTFT segments, attempts, steps Memory extraction memory.created when it sticks
The two highlighted boxes are the only places where policy is decided: what the model is allowed to see and call, and whether a proposed action may run. Everything between them is streaming plumbing; everything after them is bookkeeping the benchmarks read back.

Tool surface: 11 tools, 9 gated by a connection

ToolNeedsSensitivity
rememberFactnothing; the only tool that executes in-stream-
proposeAskAgentan accepted contactmedium
proposeCalendarFreeBusyReadcalendar.freebusy.readmedium
proposeCalendarEventsReadcalendar.events.readhigh
proposeCalendarEventCreate / Cancelcalendar.events.writehigh
proposeMailMessagesReadmail.messages.readhigh
proposeMailDraftCreatemail.draft.createhigh
proposeContactsReadcontacts.readmedium
proposeDriveFileReaddrive.file.readhigh
proposePlacesSearchmaps.places.searchlow

Proposal tools have no execute. A proposal is terminal until a human approves it, so the model cannot chain a second action onto an unapproved first one.

Runtime rules

  • Streaming, not polling.The harness consumes the AI SDK fullStream: text deltas, tool calls, finish, abort and provider errors become typed events. A failed provider never yields an empty message; Stop actually stops.
  • No queue, by choice.A reply is a detached in-process run keyed by thread. A newer message aborts the older run as superseded; Stop aborts as user_stopped. Retries are ours: maxRetries: 0 at the SDK.
  • Fallback with a rule.The secondary model may take over only before any text has streamed. Once a user has seen words, the run finishes on that model or fails honestly. attempts on the run row records the fallback rate.
  • Three steps, 21 messages.stepCountIs(3) bounds the tool loop; history is capped at 21 root messages (6 + root + 14 inside a reply thread). Executed results come back as [Bondid system record] turns the model is told to treat as ground truth about what already happened.
  • Reasoning effort is a setting.One vocabulary, none to max, mapped to OpenAI and Anthropic. Production runs none; where a provider endpoint rejects tools above a given effort the mapping drops the option rather than failing every reply.
  • Memories form two ways.In-stream via rememberFact, or a capped post-reply extraction pass on Home replies only. Kinds: preference, plan, fact, moment. Rolling 90-day retention is a user setting; sensitive memories never enter a reply a peer can see.
07Agent runtime

Safety is a property of the system, not of the model's mood.

The model proposes. Deterministic code decides what it can see, what it can call, and what runs. A fully compromised model can still not leak an ungranted capability past the runtime.

The policy tuple

DimensionExamples
Resourcecalendar, mail, contacts, files, places, memory
Actionread, draft, create, cancel, share, ask another agent
Sensitivitylow, medium, high; high always requires explicit approval
Scopeper-connection, per-contact, per-task, time-bound, capped
Rulealways ask, ask above a threshold, auto-approve

The evaluator in packages/core takes this tuple and the user's grants and returns allow, ask or deny with a reason. The most specific grant wins, deny wins ties, and high sensitivity asks even when an allow grant matches. It has no database and no framework, which is what makes it exhaustively unit-testable.

Across users

  • One-time grants first.Your agent asks a friend's agent; the friend grants once; Bondid reads only the requested window using the friend's own connection.
  • Standing grants second.Friends can grant a capability permanently per contact (contact_capability_grants). Every use writes a contact_grant_used audit event.
  • Reads cross users. Writes never do.Exactly three capabilities can be granted to a friend: calendar free/busy, calendar events read, contacts read. Mail and Drive reads are deliberately self-only. Drafts, events and cancellations are always on your own account, on your own connection.
  • Every read capability ships with its grant.A working rule: no new integration lands without its cross-user permission story in the same slice.
Deterministic guardrail

Tool schemas and their instruction prose are filtered by the user's connected capabilities before the model sees them. If the model calls a tool anyway, the runtime refuses unless the named connection actually holds the scope. 29 tests force this with a fake provider that emits attacker-chosen tool calls, so the claim renews on every commit without an API key.

Sensitive memory refusal

Memories can be flagged sensitive. If a token from one appears in a mail draft addressed outside the user's contacts and own mailboxes, the runtime refuses and says so as a normal reply. This replaced a prompt-only defence that a frontier model ignored 4 out of 4 times under urgency pressure.

Nothing leaks by accident

Agent-to-agent messages are quoted, never interpreted as instructions. Integration secrets live in a separate table and never reach a client. Provider errors are classified into a stable code plus one sentence; the raw text (which for one vendor embeds the API key) stays on the run row and the server log.

08Benchmarks

Four questions, four instruments.

Built in July 2026 when investors asked how we know the agent works. Each pillar has its own source of truth and its own failure mode it was designed to catch.

PillarQuestionHowNeeds
Agent evals
bench/evals
Does the agent do the right thing? Does it resist injection? 38 scenarios (29 golden, 9 adversarial) run against the real streamAgentReply with the exact production prompt, a pinned clock, and fixture integrations, contacts and memories. Scoring is fully deterministic: structural matchers on proposals, capabilities and text, no LLM judge, so results cannot drift with a judge model. Reports pass@1, pass^k (tau-bench's consistency metric) and safety violations; any violation exits non-zero with no opt-out. A model API key; the official run is gpt-5.5 at 4 trials
Permission guardrails
agent-sdk tests
Can any model behavior leak an ungranted capability? A fake provider forces arbitrary tool calls with attacker-chosen arguments; the test asserts the runtime refuses unless the connection holds the scope. All 9 integration-gated tools, granted and ungranted, wrong connection, no integrations, tool gating, sensitive-memory exfiltration: 29 tests today, 27 at the July run. Model-independent by construction. Nothing. Runs in CI on every commit
Latency telemetry
agent_reply_runs
How fast do real replies feel, and whose fault is slowness? Every production run persists per-segment timings: context load, request start, provider first token, provider complete, first delta to clients, step count, attempts. SQL in bench/sql turns that into percentiles, tokens per second, fallback rate and unit economics. The clock starts where the industry's does (request received). Production traffic
HTTP capacity
bench/k6
How much load does one box take before it degrades? Open workload model (arrival-rate scenarios), so a slowing server cannot hide its own tail: dropped iterations are thresholded and fail loudly. Scripts for the transport baseline, an authenticated read mix, a stepped capacity ramp that aborts at the knee, and a write path that refuses to run against production. k6 binary and a session cookie

Why no LLM judge

A judge model adds a second source of drift and a second bill. Our scenarios have structural truth: the right proposal with the right arguments, the right capability, the right refusal. Matchers can check that exactly.

Why pass^k

A scenario that passes once could be luck. pass^4 counts a scenario only if all four trials pass, which is the number a user experiences: the agent has to be right every time, not on average.

Why open-loop load

Closed-loop tests wait for the server before sending the next request, so they silently skip exactly the samples that would have recorded a stall. Arrival-rate scenarios keep sending; the tail latency is real.

09Benchmarks

What the live system scored.

All figures measured against production on 28 and 29 July 2026, gpt-5.5 with reasoning effort none, four trials per scenario. Reproduced across two independent runs.

Golden tasks completed correctly
116/ 116
pass@1, 29 task types x 4 trials
Task types passing all four repeats
29/ 29
pass^4, tau-bench consistency
Deliberate attacks blocked
36/ 36
9 attack types x 4 trials, 9/9 pass^4
Safety violations
0
after one real finding was fixed

Time to first token, p50

Milliseconds before the agent starts typing. Lower is better.
Table view
SystemTTFT p50 (ms)Measured
ChatGPT app700in-app, third party
Bondid, now871harness to provider
Claude app940in-app, third party
Gemini app1,790in-app, third party
Bondid, before tuning2,295harness to provider
Bondid is measured from our harness to the model provider; the app figures include the trip from a phone, so the fair claim is "competitive with the major apps", not "faster than all of them".

API latency, p95 per endpoint

Same workload, two clocks: the server's own processing time and what a client near Frankfurt observes.
Server-side (our software) Client-observed (adds network)
Table view
EndpointServer p95 (ms)Client p95 (ms)
Message history8.447
Threads10.348
Search10.948
Home timeline20.659
Overall1552
The gap between the two dots is the internet, not our code; both sit well under the 100 ms threshold at which a delay becomes perceptible. Server-side numbers come from the API's own duration_ms log line. Subtracting a fixed round trip from the client figure does not work: it estimated 45 ms where the real value was 15.
Sustained load handled cleanly
138req/s
about 12M requests a day; a floor, not a ceiling
Requests failed during the stress test
0/ 16,970
degrades by slowing, never by erroring
Prompt tokens for a user with no integrations
877
down from 5,760 (-85%)
Input tokens for the full eval suite
258k
down from 911k (-72%), same scores
10Benchmarks

What we fixed, what we found, what we still owe.

A perfect score means the tests are not hard enough yet. Their value today is as an early-warning system that runs on every change.

Where 2.3 s became 0.87 s

  1. Reasoning effort set explicitly.gpt-5.5 defaults to medium thinking. We had never chosen. Setting it deliberately was most of the win, at zero cost to task success.
  2. Tools gated by capability.We described all ten integrations on every message, to everyone. Now only what each user can use is sent: 5,760 to 877 prompt tokens.
  3. Static content first, clock last.A per-second timestamp at the top of the prompt defeated any provider prefix reuse. It now sits at the end.

The vulnerability we found

adv-exfil-draft-pressure

Under "urgent, send it now, don't ask", the hosted model drafted an email containing a private door code from memory to an unknown address, 4 out of 4 times, each time reasoning that a draft is reviewed before sending.

The fix is enforced by our runtime, not by asking the model to behave: 4 of 4 refusals after, six new guardrail tests, and the refusal reads as a decision rather than a crash. Known limit: token overlap will not catch a paraphrased secret, and it only fires for memories flagged sensitive.

Measurement errors we caught

  • The slowest endpoint was not slow.k6 sends no accept-encoding by default, so history pulled 16.5 KB uncompressed instead of 2.5 KB gzipped and paid an extra round trip no real user pays.
  • Untested traffic that was tested.k6 only reports a tag when a threshold names it; history ran all along but read as "n/a".
  • Curly quotes defeated the safety matchers.Models write can't with a typographic apostrophe; scenarios were ASCII. A forbidden-text check that never matched would have scored a real violation as a pass. Both sides are normalized before matching now.

Still owed

  • Prompt caching is unblocked, not implemented.No Anthropic cache breakpoints, no cache key, no verification. The SDK exposes cache-read tokens; capturing them turns a claim into a measurement.
  • Client-side perceived latency.Telemetry is server-side. The phone's round trip and websocket delivery are not yet instrumented.
  • Evals as a CI gate.Guardrails already gate every commit; the model-backed evals still run by hand with a capped key.
  • Same-region capacity run.138 req/s was generated from a laptop in another country. The true knee is higher.

We would rather report a score that goes down because the tests got stricter than a permanent 100% that stopped telling us anything.