Skip to main content

System overview

User <-> React SPA <-> Cloudflare Worker <-> Claude API
              |               |
          WebSocket    Durable Object (agent state)
                              |
                      SAP Connector (Java/JCo)
                           ^
                workspace_connections (N per workspace)
The platform has four layers:
  1. Frontend — React SPA with WebSocket streaming, tool approval UI, and file attachments
  2. Worker — Cloudflare Worker handling HTTP routing, auth, rate limiting, and Durable Object orchestration
  3. Durable Object — One per agent. Holds conversation state, manages the Claude agentic loop, executes tools, and gates write operations behind approval
  4. Connector — Java 21 Spring Boot service that talks to SAP via JCo. Each workspace can configure multiple named SAP connections via workspace_connections

Entry point

src/index.ts exports:
  • Agent — The Durable Object class (re-exported for Cloudflare binding)
  • Default { fetch, scheduled } — Worker handlers
The Worker uses Hono.js for routing. Subroutes:
RouteHandlerPurpose
/api/orgsorgs.tsOrganization CRUD
/api/workspacesworkspaces.tsWorkspace CRUD + connection CRUD (workspace_connections)
/api/workspaces/:id/indexindexing.tsSAP metadata indexing
/api/workspaces/:id/sync-runsindexing.tsSync run history
/api/workspaces/:id/dashboarddashboard.tsConnection analytics, diff comparison, object inventory (details)
/api/workspaces/:id/clean-coreclean-core.tsClean Core / readiness analysis
/api/workspaces/:id/docsdocs.tsGenerated documentation
/api/workspaces/:id/sourcesource.tsABAP source retrieval by connectionId
/api/workspaces/:id/connector-healthconnection-test.tsConnector reachability check for setup wizard
/api/workspaces/:id/test-connection-previewconnection-test.tsTest unsaved connection settings during setup wizard
/api/agents/:id/*agents.tsChat, messages, approval, WebSocket
/api/user/sap-credentials*credentials.tsPer-user SAP credential CRUD (encrypted at rest)
/api/workspaces/:id/connections/:connId/secrets*credentials.tsAdmin connection secret management
/api/filesfiles.tsR2 file upload/download
/api/sandbox/:agentId/tool-callsandbox-callback.tsSandbox tool callbacks (token auth, no JWT)
/api/workspaces/:id/test-connectionconnection-test.tsSAP connection + permission testing
/api/healthindex.tsWorker health check
Auth middleware validates Supabase JWTs on all /api/* routes. Rate limiting (60 req/min per user) applies to mutation endpoints.

Durable Object: Agent

Each agent is an Agent that extends DurableObject<Env>. It persists its own state in Durable Object storage and handles WebSocket connections for real-time streaming.

Persistent state

Stored in DO storage, survives hibernation:
KeyTypeDescription
status'idle' | 'active' | 'waiting'Agent lifecycle
messagesMessage[]Full conversation (user, assistant, tool)
modelMessagesModelMessage[]Lossless provider-agnostic history (text, tool-call, tool-result, reasoning, approvals)
pendingApprovalGroupApprovalGroupGrouped write-tool approvals awaiting user response
agentIdstringSupabase record ID
connectionsConnectionInfo[]Workspace connections (id, name, systemType, connectorUrl)
connectionSecretsRecord<string, Record<string, string>>Per-connection secrets (including connector_key)
workspaceIdstringParent workspace
llmProvider'anthropic' | 'openai'Active model provider
llmModelstring?Provider/model override in provider:model format

Write-through sync and restoration

DO storage is the primary data store during active use, but it can be evicted after a 7-day idle alarm. To survive eviction, the agent syncs state to Supabase as a non-blocking write-through:
  1. Messages — Upserted to the messages table (keyed by message ID) after every saveState() call
  2. model_messages + llm_model (+ derived llm_provider) — PATCHed to the agents row for lossless restoration and provider/model continuity
When a DO wakes with empty storage but has an agentId breadcrumb, restoreFromSupabase() rebuilds state:
  1. Fetches messages from the messages table (ordered by seq)
  2. Fetches model_messages, llm_provider, and llm_model from the agents row — llm_model is canonicalized to provider:model and provider is derived from it. If model_messages exists, uses it directly (lossless). If null, falls back to rebuildModelMessages() (lossy text-only reconstruction)
  3. Re-saves to DO storage for future fast reads
No blob storage for messages. Unlike LangChain/LangGraph’s postgres checkpointer which stores conversation state as blobs, we sync messages as individual rows to Supabase’s messages table. This gives us queryability and avoids large blob reads, but means we’re making more individual writes. If message volume grows significantly, consider a blob-based approach or more aggressive batching.

In-memory state

Not persisted — regenerated on wake:
  • sessions — Map of connected WebSocket clients
  • streamingAccumulator / thinkingAccumulator — Partial content for reconnecting clients
  • currentAbortController — Active generation controller (for stop support)
  • cachedSystemPrompt — System prompt with 5-minute TTL
  • userCredentials — Per-request decrypted SAP credentials (never persisted to DO storage)
  • sandboxToken / sandboxContextId — Sandbox session state (see Sandbox Code Execution)

Request lifecycle

POST /api/agents/:id/message
  → Worker forwards to Durable Object via stub
    → Agent.processUserMessage()
      → Adds message to state
      → Fetches attachments from R2 (PDF/image)
      → Triggers title generation (first message only)
      → Calls generateResponse()

Agentic loop: generateResponse()

This is the core loop. It runs via AI SDK v6 streamText() and supports Anthropic and OpenAI models.
1

Build tools

Calls buildSapTools({ connections: ConnectionInfo[] }) to get the SAP tool set. Base SAP tools are exposed as sap_* and route using connection_id. Cross-connection ETL tools appear only when 2+ connections are configured. If the SANDBOX binding is available, execute_code is added with a dynamically generated description containing return schemas from all active tools.By default, SAP/testing tools are execute-code-callable but not direct-callable (direct_call_allowed=false, execute_code_allowed=true). Web tools remain direct provider tools.Separately, web_search / web_fetch are conditionally added when the workspace has them enabled and at least one allowed domain is configured (Anthropic provider only). See Web Search & Fetch.
2

Stream model response

Calls streamText() with selected provider/model, system prompt, message history, tools, and stepCountIs(MAX_LOOP_STEPS) loop control. Thinking/text deltas are buffered and broadcast to WebSocket clients every 100ms. Tool lifecycle events (tool-input-start, tool-input-delta, tool-call, tool-result, tool-error, tool-approval-request) update tool cards and approval state in real time.Model selection precedence for the main loop is: llmModel (agent override) → PRIMARY_MODEL (env). Both use provider:model format.
3

Process stream events

The agent builds UI messages from streamed thinking/text/source events, tracks citations, creates early tool cards on tool-input-start, streams draft tool input on tool-input-delta, then finalizes tool args on tool-call. Tool cards are updated on tool-result / tool-error / tool-output-denied.
4

Gate write tools

Approval-required tools create a pendingApprovalGroup. The agent broadcasts approval_needed and waits for approve/reject actions.
5

Continue or stop

After approvals resolve, the agent appends a role: 'tool' message containing tool-approval-response parts, then calls generateResponse() again to continue. Without pending approvals, the agent transitions to idle.

Approval flow

Top-level tool approvals are typically driven by execute_code: the backend evaluates nested helper usage in submitted Python and only requests approval when at least one referenced nested tool is write-capable. (Web tools are direct provider tools and do not use this approval path.)
  1. Agent broadcasts approval_needed with tool name, arguments, and a unique toolCallId
  2. Frontend shows an ApprovalCard for each pending tool
  3. User clicks approve or reject (individually or bulk)
  4. POST /api/agents/:id/approve resolves the pending entry
  5. If approved → tool executes, result stored
  6. If rejected → error result stored (“User rejected this tool call”)
  7. Once all entries in the group are resolved, checkGroupComplete() merges results and continues the loop

WebSocket protocol

Clients connect via GET /api/agents/:id/ws (WebSocket upgrade). Messages are JSON with a type field:
TypeDirectionPayload
initServer → ClientFull state snapshot (messages, status, pending approvals, partial stream)
statusServer → ClientStatus change (active, idle, waiting)
messageServer → ClientNew complete message
thinking_start / thinking_endServer → ClientExtended thinking boundaries
streamServer → ClientText delta (partial content)
stream_endServer → ClientStream finished
approval_neededServer → ClientWrite tool awaiting approval
tool_updateServer → ClientPatch to tool message state (tool input drafts/final input and/or tool output)
tool_errorServer → ClientTool execution error
tool_rejectedServer → ClientTool output denied/rejected
title_updateServer → ClientAuto-generated conversation title
stoppedServer → ClientUser interrupted generation
The Durable Object uses the Hibernation API (ctx.acceptWebSocket, ctx.getWebSockets()) so connections survive DO hibernation.

Tool system

Tool definitions

Base tools are defined in src/tools/tool-definitions.ts. Each tool has:
interface ToolDefinition {
  name: string;
  description: string;
  parameters: z.ZodType<any>;    // Validated at runtime
  needsApproval: boolean;       // true = write tool
  returnDescription: string;    // Return dict shape, auto-propagated to execute_code docs
  execute: (args, context) => Promise<unknown>;
}

Tool routing

buildSapTools() in src/tools/sap-tools.ts exposes sap_* tools that include a connection_id parameter:
  • Examples: sap_read_table, sap_get_table_schema, sap_execute_rfc, sap_search_index
  • Single-connection context: connection_id defaults automatically
  • Multi-connection context: connection_id is required
Each tool resolves the target connection from ToolExecutionContext.connections and routes the connector request to that connection’s URL. By default these SAP/testing tools are callable from execute_code helpers and not directly callable by the model unless a tool explicitly sets direct_call_allowed=true.

Server tools (web search & fetch)

Web search and web fetch are added in buildAiTools() as Anthropic provider tools (anthropic.tools.webSearch_20250305 / webFetch_20250910) when llmProvider === 'anthropic', workspace toggles are enabled, and web_allowed_domains is configured. Domain restrictions are enforced by allowedDomains. See Web Search & Fetch for full details.

Cross-system tools

When 2+ connections are configured, additional tools are added that operate across connections:
  • generate_field_mapping — Compares schemas between source and target
  • preview_data_transform — Shows before/after data transformation
  • ETL tools (suggest_transforms, validate_migration, extract_transform_load, reconcile_migration)
These tools accept source_connection_id and target_connection_id parameters to route calls to the correct connectors.

Tool execution context

Every tool receives a context object at execution time:
interface ToolExecutionContext {
  connections?: ConnectionInfo[];
  connectionSecrets?: Record<string, Record<string, string>>;
  userCredentials?: Record<string, { sapUser: string; sapPassword: string }>;
  workspaceId?: string;
  environment?: string;
  searchSapIndex?: (query, category, detail, limit, connectionId, mode?) => Promise<unknown>;
}

Connector client

src/tools/sap-connector-client.ts handles HTTP communication with the Java connector. callConnector(connectorUrl, auth, endpoint, body, environment):
  1. POSTs JSON to the connector endpoint
  2. Adds X-Connector-Key header when auth.connectorKey is present
  3. Adds X-SAP-User / X-SAP-Password headers when user credentials are present
  4. Returns response.json().data on success
  5. Blocks redirects (security measure)
URL validation (validateConnectorUrl):
  • Blocks private IPs (10.x, 192.168.x, 172.16-31.x, 169.254.x) and cloud metadata IPs
  • Allows localhost/127.x only in non-production environments
  • Enforces HTTPS in production
Mock fallback: If no connectorUrl is configured, tools fall back to mock data generators in src/tools/mock-data.ts.

System prompt

Built dynamically in src/agents/system-prompt.ts. The prompt includes:
  • Base instructions (SAP domain knowledge, tool usage patterns, safety rules)
  • SAP index fingerprint (if the workspace has indexed SAP metadata — includes table counts, BAPI counts, custom object counts)
  • ETL execution guidance (mandatory validation → dry run → execute → reconcile)
  • Transform type reference (12 valid types)
  • BAPI verification rules (verify on target system, not source)
The prompt is cached in-memory with a 5-minute TTL to avoid rebuilding on every turn.

Frontend

Stack: React 18, React Router 7, Vite, Tailwind CSS, Radix UI, SWR Key components:
ComponentFilePurpose
ChatInterfacecomponents/chat/ChatInterface.tsxWebSocket connection, message rendering, streaming, approval UI
MessageBubblecomponents/chat/MessageBubble.tsxMarkdown rendering, code blocks, thinking blocks, citation links
ApprovalCardcomponents/chat/ApprovalCard.tsxApprove/reject UI for write tools
ToolCardcomponents/chat/ToolCard.tsxCollapsible tool execution results
Sidebarcomponents/layout/Sidebar.tsxOrg switcher, workspace list, agent list
State management:
  • AuthContext — Supabase auth session
  • DashboardContext — Shared workspace/agent state
  • SWR for API data fetching with automatic revalidation
Streaming:
  • Frontend accumulates stream WebSocket events into the current message
  • stream_end finalizes the message
  • Thinking blocks render in a collapsible UI
  • Reconnecting clients receive full state (including partial stream content) via the init message

Data flow

Message storage

Conversation messages live primarily in Durable Object storage during active use, with a write-through copy in Supabase for durability:
  • messages table — UI-facing conversation (user, assistant, tool messages with seq ordering). Written via non-blocking upsert after each saveState().
  • agents.model_messages — Full provider-agnostic model conversation as JSONB. PATCHed alongside message sync.
  • agents.llm_model — Canonical model selection (provider:model) for the agent. agents.llm_provider is derived from this value for compatibility.
  • DO ctx.storage — Primary store during active sessions. Provides strong consistency and low latency.
The DO is authoritative while alive. Supabase serves as the recovery source when DOs are evicted after the 7-day idle alarm.
Database infrastructure learnings (the hard way):
  1. Use the Supabase REST API, not direct Postgres connections. Direct database connections bypass Supabase’s native request pooling. Under high read/write volume, you may hit connection limit errors constantly. Switching to the REST API (/rest/v1/*) fixed this — Supabase handles connection pooling internally.
  2. Batch your operations. Even with the API, unbatched individual writes can cause rate limit and timeout issues during heavy checkpointing. We batch up to BATCH_SIZE = 50 messages from DO memory and upsert them in one request using the resolution=merge-duplicates pattern.
  3. Non-blocking writes are essential. Write-through uses ctx.waitUntil() so database latency doesn’t block the user response. If Supabase is slow or briefly down, the agent keeps working — sync retries on the next saveState() call.

File attachments

Uploaded via POST /api/files → stored in Cloudflare R2 (FILE_STORAGE bucket). The agent fetches files from R2 when processing messages with attachments. Supports PDF and image files, which are sent as model file/image content parts.

SAP metadata indexing & enrichment

Indexing and enrichment run as a two-phase pipeline:
  1. Indexing (fast, metadata-only)runFullIndex() calls the connector phases: index-custom-objects, index-relationships, index-custom-code, index-catalog-context, index-enhancements, and index-s4-extensions (S/4HANA only).
  2. Enrichment (slow, doc + analysis)SAPSyncWorkflow generates docs, infers business processes, runs Clean Core or conversion readiness analysis, and generates embeddings.
Key storage tables:
TablePurpose
workspace_sap_indexPhase-by-phase indexing status + raw data
workspace_sap_graphConnection-level status, fingerprints, progress
sap_search_entriesNormalized searchable objects (replaces workspace_sap_graph.search_index)
sap_generated_docsModule, technical, ER, and business-process docs
clean_core_analysisClean Core / conversion readiness results
sap_sync_runsFull/delta/enrich-only sync history
connection_secretsPer-connection secret key/value storage (connector_key, host/sysnr/client/router)
user_connection_credentialsPer-user encrypted SAP credentials keyed by connection
The agent search tools read from sap_search_entries (RPC-backed) and use embeddings when available.

Sync workflow (Cloudflare Workflows)

The SAPSyncWorkflow (binding: SAP_SYNC_WORKFLOW) orchestrates indexing and enrichment in the background per connection:
  1. Probe (/api/sap/probe-changes) to skip no-op delta runs
  2. Index metadata with runFullIndex(workspaceId, connectionId, ...) and update workspace_sap_graph
  3. Enrich by generating docs, inferring processes, running Clean Core or conversion readiness, and embedding docs
The scheduled cron handler (src/routes/scheduled.ts) triggers weekly full runs and daily delta runs based on last_full_sync_at / last_delta_sync_at. Progress is surfaced via workspace_sap_graph.sync_* and workspace_sap_graph.enrichment_* fields.