App~/docsmenu+close×
01Quickstart/start02Environment/start03Architecture/system04API Map/reference05Migrations/data06Custom Tools/extend07Customization/extend08Licence/legal
Back to App
~/docs
01Quickstart→02Environment→03Architecture→04API Map→05Migrations→06Custom Tools→07Customization→08Licence→
AgentZero / docs
v0.1 · built in SA

AgentZero API Map

Comprehensive reference for all API endpoints, server actions, and database operations.

end

Server Actions

All server actions are defined in /lib/actions/ and use React 19's native Server Action serialization (no custom encoding layer). Authentication is enforced at the start of each function via auth() from Auth.js v5.

Agent Management (lib/actions/agent-crud-actions.ts)

getOrgAgents()

  • ›Purpose: Fetch all agents for the authenticated user's organisation
  • ›Returns: Agent[]
  • ›Type: Agent = { id, name, instructions, organisation_id, created_at }
  • ›RLS: Scoped to session's orgId
  • ›Example:
    $  snippetread-only
    const agents = await getOrgAgents();

createAgent(name?: string, instructions?: string)

  • ›Purpose: Create a new agent for the current organisation
  • ›Params: Optional agent name and system instructions
  • ›Returns: { success: true; id: string } | { success: false; error: string }
  • ›Side Effects: Auto-names unnamed agents as "Agent N"
  • ›RLS: Writes scoped to authenticated user's orgId
  • ›Example:
    $  snippetread-only
    const result = await createAgent("My Agent", "You are a research agent..."); if (result.success) console.log(result.id);

updateAgent(agentId: string, fields: { name?: string; instructions?: string })

  • ›Purpose: Update agent name and/or instructions
  • ›Returns: { success: true } | { success: false; error: string }
  • ›Validation: Verifies agent belongs to session's organisation
  • ›Example:
    $  snippetread-only
    const result = await updateAgent(agentId, { instructions: "New system prompt" });

deleteAgents(agentIds: string[])

  • ›Purpose: Delete one or more agents
  • ›Returns: { success: true; deletedCount: number } | { success: false; error: string }
  • ›Side Effects: Cascades to all linked conversations and documents
  • ›RLS: Only agents in session's organisation can be deleted
  • ›Example:
    $  snippetread-only
    const result = await deleteAgents(["uuid-1", "uuid-2"]);

duplicateAgent(agentId: string)

  • ›Purpose: Clone an agent including its instructions
  • ›Returns: { success: true; id: string } | { success: false; error: string }
  • ›Side Effects: Creates new agent with name suffix "(copy)"
  • ›Example:
    $  snippetread-only
    const clone = await duplicateAgent(originalAgentId);

Memory & Knowledge Base (lib/actions/agent-crud-actions.ts)

saveMemoSummary(content: string, agentId: string, title?: string, tags?: string[])

  • ›Purpose: Save a message or insight to the agent's long-term memory
  • ›Returns: { success: true; id: string } | { success: false; error: string }
  • ›Side Effects: Generates vector embedding; logs to audit with timestamps
  • ›Embedding Model: OpenAI text-embedding-3-small (1536 dimensions)
  • ›RLS: Stored with user_id + organisation_id scoping
  • ›Example:
    $  snippetread-only
    const memo = await saveMemoSummary( "Discussed quarterly roadmap with CEO", agentId, "Q1 Planning", ["business", "planning"] );

searchMemos(query: string, limit?: number, minSimilarity?: number)

  • ›Purpose: Semantic search across memo summaries
  • ›Params: query (search text), limit (default 5), minSimilarity (default 0.5, range 0-1)
  • ›Returns: { success: true; results: MemoSearchResult[] } | { success: false; error: string }
  • ›Result Type: MemoSearchResult = { id, agent_id, title, content, tags, similarity }
  • ›RLS: Results filtered to authenticated user's organisation
  • ›Example:
    $  snippetread-only
    const search = await searchMemos("product strategy", 10, 0.4);

Agent Streaming & Execution (lib/actions/agent-actions.ts)

streamAgentAction(prompt: string, agentId?: string, options?: {...})

  • ›Purpose: Stream an agent response (ReAct loop) with real-time tool execution
  • ›Params:
    • ›prompt: User query (1–10,000 chars)
    • ›agentId: Optional scoped agent (validates org membership)
    • ›options.modelId: Model ID override
    • ›options.enabledTools: Array of tool IDs to enable (default: all)
    • ›options.attachments: Content with image attachments
  • ›Returns: AsyncIterable<StreamEvent>
  • ›Stream Event Types:
    $  snippetread-only
    type StreamEvent = | { type: "text-delta"; delta: string } | { type: "tool-call"; toolName: string; toolCallId: string; input: unknown } | { type: "tool-result"; toolName: string; toolCallId: string; output: unknown } | { type: "done" } | { type: "error"; message: string };
  • ›Side Effects:
    • ›Pre-flight RAG context injection (cached 60s per agent/org)
    • ›Optimistic credit deduction (rolled back on error)
    • ›Agent instructions override with custom system prompt
  • ›Tools Available: webSearchTool (real-time web), knowledgeSearchTool (semantic search, invisible RAG), emailAutomateTool (Resend), dbReadTool + dbWriteTool (Supabase)
  • ›Max Steps: 10 ReAct loop iterations before stop (via stopWhen: stepCountIs(10))
  • ›Credit Deduction: 1 credit per run (configurable)
  • ›Example:
    $  snippetread-only
    const stream = await streamAgentAction("What are our key metrics?", agentId); for await (const event of stream) { if (event.type === "text-delta") console.log(event.delta); }

Authentication (lib/actions/auth-actions.ts)

loginAction(prevState: null, formData: FormData)

  • ›Purpose: Server Action for form-based login
  • ›Input Schema:
    $  snippetread-only
    { email: string (email format), password: string (min 8 chars) }
  • ›Returns: { success: true } | { success: false; error: string }
  • ›Side Effects:
    • ›Pre-flight email existence check (caches in React request lifecycle)
    • ›Delegates to signIn('credentials', ...) → authorize() callback
    • ›Argon2 password verification (no plaintext storage)
    • ›Redirects to /dashboard on success
  • ›Security: Never confirms whether an email exists (enumeration prevention)
  • ›Usage: Bound to login form via React 19's useActionState(loginAction, null)
  • ›Example:
    $  snippetread-only
    const [state, formAction] = useActionState(loginAction, null); return <form action={formAction}>...</form>;

registerAction(prevState: null, formData: FormData)

  • ›Purpose: Sign-up with automatic organisation + user creation
  • ›Input Schema:
    $  snippetread-only
    { name: string (min 2 chars), orgName: string (min 2 chars), email: string (email format, unique), password: string (min 8 chars) }
  • ›Returns: { success: true } | { success: false; error: string }
  • ›Side Effects:
    • ›Creates organisations row with slug (unique constraint)
    • ›If slug collision: retries with 4-char random suffix
    • ›Creates users row with argon2 password hash
    • ›Orphan cleanup: deletes organisation if user insert fails
    • ›Auto-signs in and redirects to /dashboard
  • ›Idempotency: Email uniqueness enforced; slug collision auto-recovered
  • ›Example:
    $  snippetread-only
    const [state, formAction] = useActionState(registerAction, null);

logoutAction()

  • ›Purpose: Sign out the user
  • ›Side Effects: Clears session and redirects to /
  • ›Example:
    $  snippetread-only
    await logoutAction();

Conversations & Chat (lib/actions/conversation-actions.ts)

createConversation(agentId: string, title?: string)

  • ›Purpose: Create a new conversation thread
  • ›Returns: { ok: true; conversationId: string } | { ok: false; message: string }
  • ›Validation: Verifies agent belongs to session's organisation
  • ›RLS: Conversation scoped to organisation_id
  • ›Example:
    $  snippetread-only
    const conv = await createConversation(agentId, "Research Session 1");

saveMessage(conversationId: string, role: "user" | "assistant", content: string)

  • ›Purpose: Append a message to a conversation
  • ›Returns: { ok: true } | { ok: false; message: string }
  • ›Validation: Conversation must belong to session's org
  • ›Side Effects: Immutable append; updates conversation's updated_at
  • ›Example:
    $  snippetread-only
    await saveMessage(conversationId, "user", "What is your recommendation?");

loadConversation(conversationId: string)

  • ›Purpose: Fetch all messages in a conversation
  • ›Returns: { ok: true; messages: Array<{ id, role, content }> } | { ok: false; message: string }
  • ›Ordering: Chronological (oldest first)
  • ›Example:
    $  snippetread-only
    const { ok, messages } = await loadConversation(conversationId);

listConversations(agentId: string)

  • ›Purpose: Fetch all conversations for an agent with stats
  • ›Returns: { ok: true; conversations: ConversationSummary[] } | { ok: false; message: string }
  • ›Summary Type: { id, title, created_at, updated_at, message_count, last_message, last_message_role, last_message_at }
  • ›Ordering: Most recent first
  • ›Efficiency: Batches message count + last message queries
  • ›Example:
    $  snippetread-only
    const { ok, conversations } = await listConversations(agentId);

updateConversationTitle(conversationId: string, title: string)

  • ›Purpose: Rename a conversation
  • ›Returns: { ok: true } | { ok: false; message: string }
  • ›Example:
    $  snippetread-only
    await updateConversationTitle(conversationId, "Q1 Planning Notes");

Billing & Credits (lib/actions/billing-actions.ts)

createProCheckout(input?: { redirectPath?: string })

  • ›Purpose: Generate a Lemon Squeezy checkout URL for Pro tier
  • ›Params: Optional site-relative redirect path (e.g., /dashboard?tier=pro)
  • ›Returns: { ok: true; url: string } | { ok: false; error: string }
  • ›Side Effects: LS API call with user_id in custom_data (webhook idempotency)
  • ›Security: Validates redirectPath to prevent open redirects
  • ›Example:
    $  snippetread-only
    const checkout = await createProCheckout({ redirectPath: "/dashboard" }); if (checkout.ok) window.location.href = checkout.url;

createFoundingCheckout(input?: { redirectPath?: string })

  • ›Purpose: Generate a checkout for the Founding tier (one-time purchase)
  • ›Returns: { ok: true; url: string } | { ok: false; error: string }
  • ›Example:
    $  snippetread-only
    const checkout = await createFoundingCheckout();

Credits & Pre-Flight (lib/actions/credit-actions.ts)

checkUserCredits(userId: string)

  • ›Purpose: Verify user has non-zero credits before executing agent
  • ›Returns: { ok: true } | { ok: false; status: 402; message: string }
  • ›Side Effects: None; read-only
  • ›Idempotency: Called once before streamAgentAction opens the stream
  • ›Example:
    $  snippetread-only
    const check = await checkUserCredits(session.user.id); if (!check.ok) return; // Insufficient credits

deductCredits(userId: string, amount?: number)

  • ›Purpose: Optimistically lock credits at stream start
  • ›Params: amount defaults to 1
  • ›Returns: { ok: true; deducted: number } | { ok: false; status: 402; message: string }
  • ›Side Effects: Atomic RPC call to deduct_user_credits(p_user_id, p_amount)
  • ›Rollback: Return value passed to rollbackCredits() on error
  • ›Example:
    $  snippetread-only
    const deduct = await deductCredits(userId, 1); if (deduct.ok) { // Stream agent, keep deducted amount }

rollbackCredits(userId: string, amount: number)

  • ›Purpose: Refund credits on stream failure or tool error
  • ›Returns: { ok: true } | { ok: false; message: string }
  • ›Side Effects: Calls refund_user_credits(p_user_id, p_amount) RPC
  • ›Error Handling: Never re-throws; logs for manual reconciliation if RPC fails
  • ›Example:
    $  snippetread-only
    try { // stream agent } catch (err) { if (deduction?.ok) await rollbackCredits(userId, deduction.deducted); }

Document Upload & Processing (lib/actions/document-actions.ts)

uploadDocument(formData: FormData)

  • ›Purpose: Upload and chunk a PDF, TXT, or Markdown file
  • ›Input: FormData with file (File) and optional agentId (string)
  • ›Returns: { success: true; documentId: string; chunkCount: number } | { success: false; error: string }
  • ›Validation:
    • ›File types: PDF, TXT, MD (max 10 MB)
    • ›Minimum 1 character of extractable text
  • ›Pipeline:
    1. ›Extract text (unpdf for PDFs, native for text)
    2. ›Chunk by 500 chars (50 char overlap)
    3. ›Generate embeddings (OpenAI text-embedding-3-small)
    4. ›Insert document row + chunks atomically
    5. ›Rollback on chunk store failure (prevents orphans)
  • ›Side Effects: Creates document + document_chunks rows; vectors stored in pgvector
  • ›Chunking Config: CHUNK_SIZE=500, CHUNK_OVERLAP=50 (hardcoded in source)
  • ›Example:
    $  snippetread-only
    const formData = new FormData(); formData.append("file", file); formData.append("agentId", agentId); const result = await uploadDocument(formData);

listOrgDocuments()

  • ›Purpose: Fetch all documents uploaded by the organisation
  • ›Returns: { ok: true; documents: Array<{ id, file_name, file_type }> } | { ok: false; error: string }
  • ›Ordering: Most recent first
  • ›Example:
    $  snippetread-only
    const { ok, documents } = await listOrgDocuments();

getDocumentContent(documentId: string)

  • ›Purpose: Fetch the full extracted text of a document
  • ›Returns: { ok: true; content: string } | { ok: false; error: string }
  • ›Use Case: Preview in Knowledge UI, re-chunking, export
  • ›Example:
    $  snippetread-only
    const { ok, content } = await getDocumentContent(documentId);
end

HTTP API Routes

All routes use Next.js App Router (/app/api/) with native request/response handling.

Authentication (/app/api/auth/[...nextauth]/route.ts)

Delegated: Routes to handlers from @/auth (Auth.js v5 configuration).

  • ›GET /api/auth/signin
  • ›POST /api/auth/signin (credentials callback)
  • ›GET /api/auth/callback/credentials
  • ›GET /api/auth/session
  • ›POST /api/auth/signout
  • ›GET /api/auth/providers
end

Lemon Squeezy Webhooks (/app/api/webhooks/lemonsqueezy/route.ts)

POST /api/webhooks/lemonsqueezy

  • ›Purpose: Receive and process Lemon Squeezy billing events
  • ›Authentication: HMAC-SHA256 signature verification (header: x-signature)
  • ›Security:
    • ›Validates raw body signature before JSON parsing
    • ›Returns 401 on invalid signature
    • ›Timing-safe comparison to prevent timing attacks
  • ›Idempotency:
    • ›Primary: SHA-256 hash of raw body → webhook_events.body_hash (UNIQUE)
    • ›Secondary: ls_order_id or ls_subscription_id deduplication in RPC layer
    • ›Duplicate receipt returns { ok: true, duplicate: true }
  • ›Supported Event Families:
    • ›Subscriptions: subscription_created, subscription_updated, subscription_expired, subscription_paused, subscription_resumed, subscription_cancelled
    • ›Invoices: subscription_payment_success, subscription_payment_failed, subscription_payment_recovered, subscription_payment_refunded
    • ›Orders: order_created, order_refunded
  • ›Dispatch Logic:
    $  snippetread-only
    subscription_* → upsertSubscription() subscription_payment_success/recovered → grantProCreditsForInvoice() subscription_payment_refunded → revokeProCreditsForInvoice() order_created → handleFoundingOrder() order_refunded → handleFoundingRefund()
  • ›Error Handling:
    • ›Returns 500 on handler failure → LS retries with exponential backoff
    • ›Returns 400 on malformed JSON or missing event_name
    • ›Logs all failures for ops dashboard review
  • ›Response:
    $  snippetread-only
    { "ok": true } // or { "ok": true, "duplicate": true } // or { "error": "..." } with appropriate status code
end

Waitlist (/app/api/waitlist/route.ts)

POST /api/waitlist

  • ›Purpose: Sign up an email for the pre-launch waitlist
  • ›Request Body:
    $  snippetread-only
    { "email": "user@example.com" }
  • ›Validation:
    • ›Email format (Zod email validator)
    • ›Unique constraint on waitlist.email
  • ›Response:
    $  snippetread-only
    { "success": true } // 201 Created { "error": "You are already on the waitlist." } // 409 Conflict { "error": "Failed to join waitlist. Please try again." } // 500
  • ›Side Effects: Fire-and-forget email notification via Resend to raynhardt34@gmail.com
  • ›Example:
    $  snippetread-only
    curl -X POST http://localhost:3000/api/waitlist \ -H "Content-Type: application/json" \ -d '{"email":"user@example.com"}'
end

Database Layer

All database access uses the Supabase admin client (lib/supabase/admin.ts) which bypasses Row Level Security. RLS is enforced at the application layer via organisation scoping in Server Actions.

Cached Query Functions (lib/supabase/queries.ts)

getUserByEmail(email: string)

  • ›Purpose: Fetch a user by email for login pre-flight check
  • ›Returns: UserRow | null
  • ›Type: UserRow = { id, email, name, organisation_id, password_hash }
  • ›Caching: React's cache() deduplicates identical calls within a single request
  • ›Example: Called twice during login (pre-flight + authorize() callback); runs exactly one query
  • ›Location: /lib/supabase/queries.ts

Database Client (lib/supabase/admin.ts)

  • ›URL: NEXT_PUBLIC_SUPABASE_URL (public, injected at build time)
  • ›Key: SUPABASE_SECRET_KEY (secret, service role, server-only)
  • ›Auth: Disabled (autoRefreshToken: false, persistSession: false) — server-side only
  • ›Usage: All Server Actions and webhooks use this client
  • ›Important: Never export to client components (taint API in next.config.ts prevents it)
end

Key RPC Functions (Supabase)

Credit System

deduct_user_credits(p_user_id UUID, p_amount INT)

  • ›Purpose: Atomically decrement credits_remaining, increment credits_used
  • ›Error: Raises exception if credits_remaining < p_amount or row not found
  • ›Called By: deductCredits() Server Action before streaming agent

refund_user_credits(p_user_id UUID, p_amount INT)

  • ›Purpose: Reverse a deduction on error
  • ›Idempotency: Safe to call multiple times (same amount)
  • ›Called By: rollbackCredits() Server Action in error handler

grant_user_credits(p_user_id UUID, p_amount INT)

  • ›Purpose: Add credits (subscription or founding purchase)
  • ›Validation: p_amount > 0
  • ›Called By: Billing webhook handlers (grantProCreditsForInvoice, handleFoundingOrder)

grant_founding_credits(p_user_id UUID, p_ls_order_id TEXT, p_amount INT)

  • ›Purpose: Atomically insert founding grant + add credits
  • ›Idempotency: ON CONFLICT (ls_order_id) DO NOTHING prevents duplicate grants
  • ›Returns: 'granted' | 'already_exists'
  • ›Called By: handleFoundingOrder() webhook handler

revoke_founding_credits(p_user_id UUID, p_ls_order_id TEXT, p_amount INT)

  • ›Purpose: Reverse a founding grant on refund
  • ›Behaviour:
    • ›If credits_remaining >= amount: deduct and mark revoked_at
    • ›If credits_remaining < amount: flag needs_manual_review (user may have already spent credits)
  • ›Returns: 'revoked' | 'needs_review' | 'not_found' | 'already_revoked'
  • ›Called By: handleFoundingRefund() webhook handler

revoke_subscription_invoice_credits(p_user_id UUID, p_ls_subscription_id TEXT, p_amount INT)

  • ›Purpose: Reverse pro subscription invoice credits on refund
  • ›Same Logic: As revoke_founding_credits() but flags subscriptions row instead
  • ›Called By: revokeProCreditsForInvoice() webhook handler

match_chunks(query_embedding VECTOR, match_threshold FLOAT, match_count INT, filter_org_id UUID)

  • ›Purpose: Semantic search across org-level document chunks
  • ›Algorithm: pgvector cosine distance (<=>)
  • ›Returns: { content TEXT, similarity FLOAT }[]
  • ›Called By: semanticSearch() in RAG context fetch

match_agent_chunks(query_embedding VECTOR, match_threshold FLOAT, match_count INT, filter_agent_id UUID)

  • ›Purpose: Semantic search scoped to a specific agent's documents
  • ›Algorithm: Same as match_chunks() but JOINs documents and filters by agent_id
  • ›Returns: { content TEXT, similarity FLOAT }[]
  • ›Called By: semanticSearchForAgent() in RAG context fetch

match_memo_summaries(query_embedding VECTOR, match_threshold FLOAT, match_count INT, filter_org_id UUID)

  • ›Purpose: Semantic search across organisation's memo summaries
  • ›Returns: Full memo rows with similarity scores
  • ›Called By: searchMemos() Server Action
end

Key Database Tables

Core Multi-Tenancy

| Table | Purpose | Key Columns | |-------|---------|------------| | organisations | Tenant root | id (PK), name, slug (UNIQUE), created_at | | users | Users tied to orgs | id (PK), email (UNIQUE), organisation_id (FK), password_hash, created_at | | agents | Org-scoped AI agents | id (PK), name, instructions, organisation_id (FK), created_at |

Conversations

| Table | Purpose | Key Columns | |-------|---------|------------| | conversations | Chat threads per agent | id (PK), agent_id (FK), organisation_id (FK), title, created_at, updated_at | | conversation_messages | Immutable message history | id (PK), conversation_id (FK), role ('user' \| 'assistant'), content, created_at |

Documents & Knowledge

| Table | Purpose | Key Columns | |-------|---------|------------| | documents | Uploaded PDFs/TXT/MD | id (PK), user_id (FK), org_id (FK), agent_id (FK, nullable), file_name, file_type, content (full text), created_at | | document_chunks | Embedded text chunks | id (PK), document_id (FK), content, chunk_order, embedding (pgvector 1536), created_at | | memo_summaries | Long-term memory | id (PK), user_id (FK), organisation_id (FK), agent_id (FK), content, title, tags (TEXT[]), embedding (pgvector 1536), created_at |

Credits & Billing

| Table | Purpose | Key Columns | |-------|---------|------------| | user_credits | Credit ledger | user_id (PK/FK), credits_remaining (INT), credits_used (INT) | | subscriptions | LS subscription history | id (PK), user_id (FK), ls_subscription_id, tier ('pro' \| 'founding'), status, renews_at, ends_at, needs_manual_review, created_at, updated_at | | founding_grants | Founding purchases | id (PK), user_id (FK), ls_order_id (UNIQUE), amount, granted_at, revoked_at, needs_manual_review, review_reason | | webhook_events | LS webhook log (idempotency) | id (PK), body_hash (UNIQUE), event_name, raw_payload (JSONB), processed_at, error, received_at |

end

Environment Variables

Required

| Variable | Purpose | Example | |----------|---------|---------| | NEXT_PUBLIC_SUPABASE_URL | Supabase project URL | https://project.supabase.co | | SUPABASE_SECRET_KEY | Supabase service role key | sb_secret_... | | NEXTAUTH_SECRET | Auth.js session encryption | 32+ char random string | | NEXT_PUBLIC_APP_URL | App's public URL (for redirects) | https://boileragent.dev |

Optional

| Variable | Purpose | Default | |----------|---------|---------| | RAG_MATCH_THRESHOLD | Embedding similarity threshold (0-1) | 0.1 | | LEMONSQUEEZY_WEBHOOK_SECRET | LS webhook HMAC key | (required for webhooks) | | RESEND_API_KEY | Resend email API key | (required for waitlist emails) |

end

Tool Registry

Tools are defined in /features/tools/, exported from features/tools/index.ts, and injected into the streamText ReAct loop. The mapping between registry IDs (used by the UI toggles) and exported names is defined in streamAgentAction():

$  snippetread-only
const idToExportName: Record<string, string[]> = { web_search: ["webSearchTool"], knowledge: ["knowledgeSearchTool"], email: ["emailAutomateTool"], database: ["dbReadTool", "dbWriteTool"], };

User-visible tool metadata (label + icon, used to render the UI toggle list) lives separately in lib/ai/tool-registry.ts. Keep these three places in sync when adding a tool.

Available Tools

  • ›webSearchTool — Real-time web search via Tavily (user-controlled via UI toggle).
  • ›knowledgeSearchTool — Semantic search over the org's uploaded documents. Always available to the model as invisible RAG plumbing; not user-toggleable.
  • ›emailAutomateTool — Send transactional email via Resend (user-controlled).
  • ›dbReadTool / dbWriteTool — Read from / insert into Supabase tables (user-controlled, single "Database" toggle).
end

Error Handling & Status Codes

| Code | Meaning | Action | |------|---------|--------| | 200 | Success | Continue | | 201 | Created (POST) | Success | | 400 | Malformed input | Fix request, retry | | 401 | Unauthorised (webhook signature) | Log signature issue, alert ops | | 402 | Payment required (insufficient credits) | Prompt user to purchase | | 409 | Conflict (duplicate email/slug) | Retry with alternative value | | 422 | Validation error | Fix input per Zod error | | 500 | Server error | LS webhook returns 500 to trigger retry |

end

Client-Server Flow Example: Stream Agent

$  snippetread-only
Client: form.onSubmit() ↓ Client: <form action={streamAgentAction}> ↓ Server: streamAgentAction(prompt, agentId) ├─ auth() → validates session, reads orgId ├─ checkUserCredits() → 402 if insufficient ├─ deductCredits() → locks credits ├─ fetchRagContext() → cache 60s, inject into system prompt ├─ streamText() → opens ReAct loop (max 10 steps) │ ├─ yield text-delta events │ ├─ yield tool-call events │ ├─ execute tools, yield tool-result events │ └─ yield done └─ catch: rollbackCredits() on error ↓ Server: Returns AsyncIterable<StreamEvent> immediately ↓ Client: for await (const event of stream) { ... } ├─ accumulates text-delta ├─ renders tool calls └─ displays final response (Optional): await saveMessage(conversationId, "assistant", finalText)
end

Rate Limiting & Quotas

  • ›Agent runs: Limited by user's credit balance (configurable cost)
  • ›Embedding generation: Sequential per document (avoid OpenAI rate limit bursts)
  • ›Webhooks: Lemon Squeezy retries with exponential backoff (idempotent via body_hash)
  • ›No explicit HTTP rate limit on Server Actions (Next.js runtime handles it)
end

Troubleshooting

"Unauthorised" on Server Action

  • ›Check: session?.user?.id exists (auth() call)
  • ›Check: session?.user?.orgId exists (organisation record linked)

RAG returns no results

  • ›Check: Documents uploaded to agent or organisation
  • ›Check: RAG_MATCH_THRESHOLD env var (default 0.1, increase for stricter matching)
  • ›Check: Embeddings generated successfully (logs may show warnings)

Webhook signature verification fails

  • ›Verify: LEMONSQUEEZY_WEBHOOK_SECRET is correct (from LS dashboard)
  • ›Verify: Raw body is read before JSON parsing (code does this correctly)
  • ›Check: LS API configuration points to correct endpoint

Duplicate webhook event processed

  • ›Check: webhook_events.body_hash is unique (migration enforces this)
  • ›Check: Processed handler returns 2xx status (LS stops retrying)
  • ›Check: Logs show { ok: true, duplicate: true } on replay

Credits not deducted

  • ›Check: user_credits row exists for user (created on registration)
  • ›Check: deduct_user_credits RPC executed successfully
  • ›Check: Check logs for "optimistic deduction" entries