# Struere Documentation — Platform Concepts > Filtered section from the Struere docs. Full docs: https://docs.struere.dev/llms.txt --- ## Data > Domain data with permission-aware CRUD operations Source: https://docs.struere.dev/platform/data.md Struere includes a built-in structured data layer where agents create, query, update, and delete typed entities with JSON schemas, full-text search, and entity relations — no external database required. The data layer enforces row-level scope rules and column-level field masks through RBAC, with automatic event emission on every mutation for trigger-based automation. ## Data Types Data types define the schema for a category of data. They are created using the `defineData` SDK function and synced to the platform. Each data type specifies: - **Schema**: JSON Schema defining the data structure - **Search fields**: Fields indexed for text search - **Display config**: How records appear in the dashboard Data types are scoped per environment using the `by_org_env_slug` index, so development and production can have different schemas. ### Data Type Schema in the Database | Field | Type | Description | |-------|------|-------------| | `organizationId` | ID | Owning organization | | `environment` | enum | `"development"` or `"production"` | | `name` | string | Display name | | `slug` | string | Unique identifier within org + environment | | `schema` | object | JSON Schema definition | | `searchFields` | array | Fields indexed for search | | `displayConfig` | object | Dashboard display options | | `boundToRole` | string | Role binding for user linking | | `userIdField` | string | Field storing Clerk user ID | ## Records Records are instances of a data type. Each record stores its data as a flexible JSON object that conforms to the data type's schema. ### Record Schema in the Database | Field | Type | Description | |-------|------|-------------| | `organizationId` | ID | Owning organization | | `environment` | enum | `"development"` or `"production"` | | `entityTypeId` | ID | Reference to data type | | `data` | object | Record data conforming to the type's JSON Schema | | `status` | string | Lifecycle status (e.g., `"active"`, `"deleted"`) | | `createdAt` | number | Creation timestamp | | `updatedAt` | number | Last update timestamp | ### Environment Scoping All data queries are scoped to the current environment using the `by_org_env_type` and `by_org_env_type_status` indexes. An agent running in the development environment cannot access production data. ## CRUD Operations All data operations are permission-aware, flowing through the permission engine before executing. ### Create ```typescript entity.create({ type: "session", data: { teacherId: "ent_abc123", studentId: "ent_def456", guardianId: "ent_ghi789", startTime: 1700000000000, duration: 60, subject: "Mathematics", status: "scheduled", }, }) ``` Creates a record and emits an event. The actor must have `create` permission on the data type. ### Read ```typescript entity.get({ id: "ent_abc123" }) ``` Retrieves a single record by ID. The response is filtered through field masks based on the actor's role, hiding or redacting fields the actor should not see. ### Query ```typescript entity.query({ type: "session", filters: { "data.status": "scheduled" }, limit: 10, }) ``` Queries records by type with optional filters. Scope rules are applied automatically, so a teacher only sees their own sessions and a guardian only sees sessions for their children. ### Update ```typescript entity.update({ id: "ent_abc123", data: { status: "completed", teacherReport: "Great progress in algebra." }, }) ``` Updates record data and emits an event. The actor must have `update` permission. ### Delete ```typescript entity.delete({ id: "ent_abc123" }) ``` Performs a soft delete (sets status to `"deleted"`) and emits an event. The actor must have `delete` permission. ## References Records can reference other records through schema-level foreign keys. Add `references: "entity-type-slug"` to any string field in your data type schema to enforce referential integrity. ### Defining References ```typescript import { defineData } from 'struere' export default defineData({ name: "Session", slug: "session", schema: { type: "object", properties: { studentId: { type: "string", references: "student" }, teacherId: { type: "string", references: "teacher" }, startTime: { type: "number" }, duration: { type: "number" }, subject: { type: "string" }, }, required: ["studentId", "teacherId", "startTime", "duration"], }, }) ``` ### What It Enforces When `entity.create` or `entity.update` is called, the platform validates every field that has a `references` value: - The referenced entity must exist - The referenced entity must be active (not deleted) - The referenced entity must belong to the same organization and environment - The referenced entity must be of the correct entity type (e.g., a field with `references: "student"` must point to a `student` entity) If any check fails, the operation throws an error identifying the invalid reference field. ## Search Data types define `searchFields` that are indexed for text search. The `entity.query` tool supports searching across these fields: ```typescript entity.query({ type: "teacher", filters: { search: "mathematics" }, limit: 5, }) ``` This searches across all fields listed in the data type's `searchFields` array. ## Permission Flow for Data Every data operation passes through the full permission pipeline: ``` Actor makes request │ ▼ 1. Permission Check Does the actor's role have a policy allowing this action on this data type? (deny overrides allow) │ ▼ 2. Scope Rules (row-level) Filter query results to only records the actor is allowed to see (e.g., teacher sees own sessions) │ ▼ 3. Field Masks (column-level) Hide or redact fields the actor should not access (e.g., teacher cannot see paymentId) │ ▼ Filtered response returned ``` ## Tutoring Domain Example The tutoring pack defines 6 data types that demonstrate the full data system: | Data Type | Key Fields | Relationships | |-----------|------------|---------------| | `teacher` | name, email, subjects, availability, hourlyRate, userId | Linked to sessions | | `student` | name, grade, subjects, notes, guardianId, preferredTeacherId | Linked to guardian | | `guardian` | name, email, phone, whatsappNumber, billingAddress, userId | Parent of students | | `session` | teacherId, studentId, guardianId, startTime, duration, status | Links teacher, student, guardian | | `payment` | guardianId, amount, status, providerReference, sessionId | Linked to session | | `entitlement` | guardianId, studentId, totalCredits, remainingCredits, expiresAt | Credits for sessions | ### Session Lifecycle Sessions follow a defined state machine: ``` pending_payment ──[payment.success]──► scheduled │ ┌─────────────────────┼─────────────────────┐ │ │ │ ▼ ▼ ▼ cancelled in_progress no_show │ ▼ completed ``` ### Scheduling Constraints - 24-hour minimum booking lead time - 2-hour reschedule cutoff - Teacher availability validation - No double booking - Credit consumption on session completion --- ## Agents > AI agent configuration and execution Source: https://docs.struere.dev/platform/agents.md Agents are the core execution units of the Struere platform, processing up to 10 LLM iterations per request with automatic tool calling, permission checking, and credit billing. Each agent runs within an ActorContext that enforces row-level scope rules and column-level field masks on every operation, supporting 40+ LLM models from OpenAI, Anthropic, Google, and xAI via OpenRouter. ## Architecture Agent data is split across two tables with different scoping rules: ### agents Table (Shared) The `agents` table stores identity information that is shared across environments: | Field | Type | Description | |-------|------|-------------| | `organizationId` | ID | Organization that owns this agent | | `name` | string | Display name | | `slug` | string | URL-safe identifier for API routing | | `description` | string | Human-readable description | | `status` | enum | `"active"`, `"paused"`, or `"deleted"` | The `slug` is used for API access via `/v1/agents/:slug/chat`. ### agentConfigs Table (Environment-Scoped) The `agentConfigs` table stores the actual configuration and is scoped per environment using the `by_agent_env` index: | Field | Type | Description | |-------|------|-------------| | `agentId` | ID | Reference to the agents table | | `environment` | enum | `"development"` or `"production"` | | `version` | string | Semantic version | | `name` | string | Config display name | | `systemPrompt` | string | Compiled system prompt | | `model` | object | Provider, model name, temperature, maxTokens | | `tools` | string[] | Array of tool names (built-in and custom) the agent can use | | `deployedBy` | ID | User who deployed this config | This split means an agent can have different configurations in development and production. The `struere dev` command syncs to the development config, and `struere deploy` promotes configs to production. ## Execution Flow When a chat request arrives, the agent executes through this pipeline: ``` POST /v1/chat or POST /v1/agents/:slug/chat │ ▼ ┌─────────────────────────────┐ │ 1. Authentication │ │ Extract Bearer token │ │ Validate API key │ │ (SHA-256 hash lookup) │ └──────────┬──────────────────┘ │ ▼ ┌─────────────────────────────┐ │ 2. Load Agent │ │ Resolve agent by ID/slug │ │ Load config via │ │ by_agent_env index │ │ (env from API key) │ └──────────┬──────────────────┘ │ ▼ ┌─────────────────────────────┐ │ 3. Build ActorContext │ │ organizationId │ │ actorType (user/agent) │ │ environment │ │ roleIds (resolved) │ │ isOrgAdmin │ └──────────┬──────────────────┘ │ ▼ ┌─────────────────────────────┐ │ 4. Prepare Thread │ │ Get or create thread │ │ (env-scoped) │ │ Load message history │ └──────────┬──────────────────┘ │ ▼ ┌─────────────────────────────┐ │ 5. Process System Prompt │ │ Resolve {{variables}} │ │ Execute embedded queries │ │ (permission-aware) │ └──────────┬──────────────────┘ │ ▼ ┌─────────────────────────────┐ │ 6. LLM Loop (max 10 iter) │ │ Call LLM API │ │ ├─ Text response → done │ │ └─ Tool calls: │ │ Check permission │ │ Execute tool │ │ Add result to context │ │ Continue loop │ └──────────┬──────────────────┘ │ ▼ ┌─────────────────────────────┐ │ 7. Persist & Respond │ │ Append messages to thread│ │ Record execution metrics │ │ Return response │ └─────────────────────────────┘ ``` ### LLM Loop The agent runs an iterative loop with a maximum of 10 iterations. Each iteration: 1. Sends the full message history (system prompt + conversation + tool results) to the LLM 2. If the LLM responds with text only, the loop exits 3. If the LLM makes tool calls, each tool is: - Permission-checked via `canUseTool` - Executed (built-in tools run as Convex mutations; custom tools run on the tool executor service) - Results are appended to the message history 4. The loop continues with the updated history ### Tool Execution **Built-in tools** run as Convex mutations with full permission checking: | Tool | Convex Function | Description | |------|-----------------|-------------| | `entity.create` | `tools.entities.entityCreate` | Create entity + emit event | | `entity.get` | `tools.entities.entityGet` | Get entity (field-masked) | | `entity.query` | `tools.entities.entityQuery` | Query with scope filters | | `entity.update` | `tools.entities.entityUpdate` | Update + emit event | | `entity.delete` | `tools.entities.entityDelete` | Soft delete + emit event | | `event.emit` | `tools.events.eventEmit` | Emit custom event | | `event.query` | `tools.events.eventQuery` | Query events (visibility filtered) | | `agent.chat` | `tools.agents.agentChat` | Delegate to another agent | **Custom tools** are sent to the tool executor service at `tool-executor.struere.dev` for isolated execution with actor context. Custom tool handlers receive a `struere` SDK parameter that provides access to all built-in tools (e.g., `struere.entity.create`, `struere.event.emit`), enabling custom tools to compose platform operations within their handler logic. Tools marked with `templateOnly: true` are executed during system prompt template compilation but are not exposed to the LLM as callable tools at runtime. ## Multi-Agent Communication The `agent.chat` tool enables agents to delegate work to other agents within the same organization and environment. ``` Caller Agent │ ├─ tool_call: agent.chat({ agent: "analyst", message: "..." }) │ ▼ Target Agent Resolution │ ├─ Find agent by slug ├─ Create child thread (shared conversationId) │ ▼ Target Agent Execution │ ├─ Full LLM loop with target's own config/tools/permissions │ ▼ Response returned as tool result to Caller Agent ``` ### Safety Mechanisms | Mechanism | Description | |-----------|-------------| | Depth limit | Maximum chain depth of 3 (`MAX_AGENT_DEPTH`) | | Cycle detection | Target slug checked against caller slug | | Iteration cap | Each agent limited to 10 LLM iterations independently | | Action timeout | Convex built-in timeout prevents infinite execution | > **Gotcha:** `agent.chat` enforces depth 3 with cycle detection — A calling B calling A is blocked. Design shallow agent graphs. See [Platform Gotchas](/platform/gotchas) for details. ### Thread Linking All threads in a multi-agent conversation share the same `conversationId`. Child threads store a `parentThreadId` linking back to the parent. Thread metadata includes: ```typescript { conversationId: string, parentAgentSlug: string, depth: number, parentContext: object, } ``` ## Thread Context Every conversation thread carries metadata about the channel it originated from and any context parameters. This data is available in system prompt templates and custom tool handlers. ### Thread Data | Field | Description | |-------|-------------| | `channel` | The originating channel: `"whatsapp"`, `"api"`, `"widget"`, or `"dashboard"` | | `channelParams` | Channel-specific metadata (see below) | | `externalId` | External identifier for thread deduplication (e.g., `whatsapp:{connectionId}:{phoneNumber}`) | | `threadContext` | Custom parameters passed by the caller or auto-populated by the channel | ### WhatsApp Channel Params When a conversation comes through WhatsApp, the thread's `channelParams` is automatically populated: ```typescript { phoneNumber: "+1234567890", contactName: "Maria Garcia", lastInboundAt: 1713384000000, } ``` | Param | Type | Description | |-------|------|-------------| | `phoneNumber` | `string` | Sender's phone number with country code | | `contactName` | `string?` | WhatsApp profile display name | | `lastInboundAt` | `number?` | Timestamp of the last inbound message | ### Accessing Thread Context in System Prompts Use template variables to inject thread context into the agent's system prompt: ``` You are a support agent for {{organizationName}}. Channel: {{threadContext.channel}} Sender phone: {{threadContext.params.phoneNumber}} Sender name: {{threadContext.params.contactName}} ``` **Example: Auto-identifying a teacher by phone number** ``` You are a scheduling assistant for {{organizationName}}. The sender's phone number is {{threadContext.params.phoneNumber}}. Look up the teacher entity matching this phone number to personalize your responses. If no teacher is found, ask the sender to identify themselves. ``` ### Accessing Thread Context in Custom Tools Custom tool handlers receive thread context via the `context` parameter. See [Custom Tools](../tools/custom-tools) for the full `ExecutionContext` interface, which includes `organizationId`, `actorId`, and `actorType`. ## Model Pricing See [Model Configuration](../reference/model-configuration) for the full list of supported models, pricing, and configuration options. ## API Endpoints | Endpoint | Method | Description | |----------|--------|-------------| | `/v1/chat` | POST | Chat by agent ID (Bearer token with API key) | | `/v1/agents/:slug/chat` | POST | Chat by agent slug (Bearer token with API key) | Both endpoints require a valid API key passed as a Bearer token. The environment is determined by the API key's environment field. ### Response Format ```typescript { threadId: string, message: string, usage: { inputTokens: number, outputTokens: number, totalTokens: number, reasoningTokens?: number, }, _executionMeta?: { iterationCount: number, model: string, durationMs: number, toolCallSummary: Array<{ name: string, durationMs: number, status: string, errorType?: string, errorMessage?: string, }>, errorCount: number, permissionDenialCount: number, }, _transferred?: { targetAgentSlug: string, targetAgent: string, }, } ``` ### Execution Metadata The `_executionMeta` field provides telemetry about the agent's execution: | Field | Type | Description | |-------|------|-------------| | `iterationCount` | number | Number of LLM iterations in the loop | | `model` | string | Model ID used (OpenRouter format) | | `durationMs` | number | Total execution time in milliseconds | | `toolCallSummary` | array | Per-tool breakdown with name, duration, status, and error details | | `errorCount` | number | Number of failed tool calls (agent may self-correct) | | `permissionDenialCount` | number | Number of tool calls denied by the permission engine | ### Router Transfer When an agent is running inside a routed thread, the `router.transfer` tool is auto-injected into its available tools, regardless of the agent's tool configuration. This allows any agent in a routed conversation to transfer the thread to another agent. When a transfer occurs, the response includes an empty `message` and a `_transferred` object containing `targetAgentSlug` and `targetAgent` (display name). The caller should use the `_transferred` flag to detect that a transfer happened and handle it accordingly (e.g., re-routing the next message to the new agent). --- ## Automations > Automated workflows triggered by data changes Source: https://docs.struere.dev/platform/triggers.md Automations are automated workflows that execute when data is created, updated, or deleted, or on a recurring cron schedule. They enable event-driven architecture by running a sequence of tool calls in response to data mutations or time-based schedules, without requiring manual intervention. ## How Automations Work Automations can be activated in two ways: - **Entity triggers**: fire when a data mutation occurs (create/update/delete) - **Cron triggers**: fire on a recurring schedule using cron expressions ### Entity Trigger Flow When a mutation occurs (from the dashboard, an agent tool call, or an API request), the automation engine checks for matching automations and schedules them for execution: ``` Data mutation (create/update/delete) │ ▼ Automation engine scans for matching automations │ ├─ Match on entityType ├─ Match on action (created/updated/deleted) └─ Match on condition (optional data filter) │ ▼ Matched automations scheduled asynchronously │ ▼ Actions execute in order (fail-fast) │ ├─ Success → trigger.executed event emitted └─ Failure → trigger.failed event emitted (retry if configured) ``` ### Execution Characteristics | Property | Behavior | |----------|----------| | Timing | Asynchronous (scheduled after the originating mutation completes) | | Actor | Runs as the **system actor** with full permissions | | Error handling | **Fail-fast** (first action failure stops the chain) | | Success event | Emits `trigger.executed` | | Failure event | Emits `trigger.failed` | | Sources | Fires from dashboard CRUD, agent tool calls, and API mutations | ## Immediate Automations By default, automations execute as soon as they are scheduled (immediately after the originating mutation). The actions run in sequence: ```typescript { name: "Notify on New Session", slug: "notify-on-session", on: { entityType: "session", action: "created", condition: { "data.status": "scheduled" }, }, actions: [ { tool: "entity.get", args: { id: "{{trigger.data.teacherId}}" }, as: "teacher", }, { tool: "event.emit", args: { eventType: "session.notification", entityId: "{{trigger.entityId}}", payload: { teacherName: "{{steps.teacher.data.name}}" }, }, }, ], } ``` ## Scheduled Automations Automations can be delayed or scheduled for a specific time using the `schedule` field: ### Delay-Based Execute after a fixed delay: ```typescript schedule: { delay: 300000, } ``` This runs the automation 5 minutes after the data mutation. ### Time-Based Execute at a specific time derived from the data: ```typescript schedule: { at: "{{trigger.data.startTime}}", offset: -3600000, } ``` This schedules the automation for 1 hour before the session's `startTime`. The `at` field supports template expressions that resolve to an ISO timestamp or Unix timestamp. ### Cancel Previous When a record is updated multiple times, `cancelPrevious` ensures only the latest scheduled run is kept: ```typescript schedule: { at: "{{trigger.data.startTime}}", offset: -3600000, cancelPrevious: true, } ``` If a session's start time is changed, the old reminder is cancelled and a new one is scheduled. ## Cron Triggers Cron triggers fire on a recurring schedule instead of entity events. Use the `on.schedule` field with a standard 5-field cron expression: ```typescript export default defineTrigger({ name: "Weekly Report", slug: "weekly-report", on: { schedule: "0 9 * * 1", timezone: "America/New_York", }, actions: [ { tool: "agent.chat", args: { agent: "reporting", message: "Generate and send the weekly summary report", }, }, ], }) ``` ### Cron Expression Format ``` ┌───────────── minute (0-59) │ ┌───────────── hour (0-23) │ │ ┌───────────── day of month (1-31) │ │ │ ┌───────────── month (1-12) │ │ │ │ ┌───────────── day of week (0-6, 0=Sunday) │ │ │ │ │ * * * * * ``` | Syntax | Meaning | Example | |--------|---------|---------| | `*` | Every value | `* * * * *` (every minute) | | `5` | Specific value | `0 5 * * *` (5am daily) | | `1-5` | Range | `0 9 * * 1-5` (9am weekdays) | | `*/5` | Step | `*/5 * * * *` (every 5 minutes) | | `1,3,5` | List | `0 0 1,15 * *` (1st and 15th of month) | ### Timezone The `timezone` field accepts any IANA timezone identifier. If omitted, the schedule runs in UTC. ### Cron Execution - Cron triggers are checked every minute by the platform - They do not have entity context (`trigger.entityId`, `trigger.data`, etc. are not available) - The `schedule` field (delay/at) is not applicable to cron triggers - Successful executions emit `trigger.executed` events - Failed executions emit `trigger.failed` events ## Automation Runs Scheduled automations create records in the `triggerRuns` table for status tracking. ### Status Lifecycle ``` pending ──► running ──► completed │ ▼ failed ──► running (retry) ──► completed │ ▼ dead (retries exhausted) ``` | Status | Description | |--------|-------------| | `pending` | Scheduled but not yet executed | | `running` | Currently executing actions | | `completed` | All actions finished successfully | | `failed` | An action failed (may be retried) | | `dead` | Failed and exhausted all retry attempts | ### Automation Run Fields | Field | Type | Description | |-------|------|-------------| | `triggerId` | ID | Reference to the automation definition | | `triggerSlug` | string | Slug of the automation definition | | `entityId` | string | Record that triggered the run | | `status` | enum | Current lifecycle status | | `data` | object | Record data at time of activation | | `previousData` | object | Record data before the mutation (for updates) | | `scheduledFor` | number | When the run is scheduled to execute | | `startedAt` | number | When execution began | | `completedAt` | number | When execution finished | | `errorMessage` | string | Error message if failed | | `attempts` | number | Current retry attempt count | | `maxAttempts` | number | Maximum retry attempts configured | | `backoffMs` | number | Base backoff delay in milliseconds | | `result` | object | Execution result on completion | | `environment` | enum | Scoped to development or production | ## Retry Configuration Failed automations can be retried with backoff: ```typescript retry: { maxAttempts: 3, backoffMs: 5000, } ``` | Field | Type | Description | |-------|------|-------------| | `maxAttempts` | number | Maximum retry attempts (minimum 1) | | `backoffMs` | number | Base delay in milliseconds between retries | When an automation fails: 1. If `attempts < maxAttempts`, the run is rescheduled with exponential backoff: `backoffMs * 2^(attempts-1)`, capped at 1 hour 2. The status transitions back to `pending` 3. On the next attempt, it transitions to `running` 4. If all retries are exhausted, the status becomes `dead` and a `trigger.scheduled.dead` event is emitted ## Template Variable Resolution Automation action arguments support template variables that are resolved at execution time. ### Automation Context | Variable | Description | |----------|-------------| | `{{trigger.entityId}}` | ID of the record that activated the automation | | `{{trigger.entityType}}` | Data type slug | | `{{trigger.action}}` | The action: `"created"`, `"updated"`, or `"deleted"` | | `{{trigger.data.X}}` | Field `X` from the record's current data | | `{{trigger.previousData.X}}` | Field `X` from the record's data before an update | ### Step References | Variable | Description | |----------|-------------| | `{{steps.NAME.X}}` | Field `X` from the result of a named step | Steps are named using the `as` field on an automation action. Later actions can reference the result: ```typescript actions: [ { tool: "entity.get", args: { id: "{{trigger.data.guardianId}}" }, as: "guardian", }, { tool: "event.emit", args: { eventType: "notification", payload: { name: "{{steps.guardian.data.name}}" }, }, }, ] ``` ## Condition Matching The `on.condition` field filters which mutations activate the automation. All condition fields must match for the automation to fire: ```typescript on: { entityType: "session", action: "updated", condition: { "data.status": "completed" }, } ``` This automation only fires when a session record is updated and its `data.status` field equals `"completed"`. Multiple conditions act as AND filters: ```typescript condition: { "data.status": "scheduled", "data.subject": "Mathematics", } ``` ### Transition Conditions For `updated` actions, conditions can also match against `previousData` — the record's data **before** the update. This enables transition-based automations that only fire when a field changes from one value to another: ```typescript condition: { "data.status": "scheduled", "previousData.status": "pending_payment", } ``` This automation fires only when a session transitions **from** `pending_payment` **to** `scheduled` — not when an already-scheduled session is updated (e.g., rescheduled). #### Example: Separate Confirmation and Reschedule Automations Fire on initial activation (payment confirmed): ```typescript on: { entityType: "session", action: "updated", condition: { "data.status": "scheduled", "previousData.status": "pending_payment", }, } ``` Fire only on reschedule (already scheduled, time changed): ```typescript on: { entityType: "session", action: "updated", condition: { "data.status": "scheduled", "previousData.status": "scheduled", }, } ``` #### Available Condition Paths | Path prefix | Description | Available for | |-------------|-------------|---------------| | `data.*` | Current record data (after mutation) | `created`, `updated`, `deleted` | | `previousData.*` | Record data before the mutation | `updated` only | For `created` and `deleted` actions, `previousData` is undefined — any condition referencing `previousData.*` will not match. ### Trigger Cascading Entity mutations inside automation actions **do not cascade by default** — they will not fire other automations. This prevents infinite loops and unexpected side effects. To explicitly allow cascading, pass `cascade: true` in the tool args: ```typescript { tool: "entity.create", args: { type: "notification", data: { message: "Session confirmed" }, cascade: true, }, } ``` Without `cascade: true`, entity mutations from automation actions are silent — they modify data but do not activate other automations. This is the safe default for common patterns like writing data back to the triggering entity (e.g., saving a calendar event ID after creation). `cascade` is supported on `entity.create`, `entity.update`, and `entity.delete` tool calls within automations ``` ## Mutation Sources Automations fire from all mutation sources in the platform: | Source | Example | |--------|---------| | **Dashboard CRUD** | Admin creates a session via the UI | | **Agent tool calls** | Agent uses `entity.create` to schedule a session | | **API mutations** | External system calls the HTTP API | | **Webhooks** | Payment provider confirms a payment via webhook | This ensures that automated workflows execute regardless of how the mutation originated. ## Payment Automations Payment state changes from webhooks and reconciliation fire automations. Use `entityType: "payment"` with `action: "updated"` and a `condition` to match specific payment states: ```typescript export default defineTrigger({ name: "Notify Payment Received", slug: "notify-payment-received", on: { entityType: "payment", action: "updated", condition: { "data.status": "paid" }, }, actions: [ { tool: "whatsapp.send", args: { to: "{{trigger.data.customerPhone}}", text: "Payment of {{trigger.data.amount}} {{trigger.data.currency}} received.", }, }, ], }) ``` See [Flow Payments](/integrations/flow-payments) for the full list of payment events and more examples. ## Events Automations emit events throughout their lifecycle: | Event | When | |-------|------| | `trigger.executed` | Immediate automation completed successfully | | `trigger.failed` | Immediate automation action failed | | `trigger.scheduled.completed` | Scheduled automation run completed successfully | | `trigger.scheduled.dead` | Scheduled automation run exhausted all retry attempts | ## Available Tools Automations can execute any built-in tool and custom tools. Actions run as the **system actor** with full permissions. ### Core Tools | Category | Tools | |----------|-------| | Data | `entity.create`, `entity.get`, `entity.query`, `entity.update`, `entity.delete` | | Event | `event.emit`, `event.query` | | Agent | `agent.chat` | | Web | `web.search`, `web.fetch` | ### Integration Tools Require an active integration configured in the dashboard. | Integration | Tools | |-------------|-------| | Google Calendar | `calendar.list`, `calendar.create`, `calendar.update`, `calendar.delete`, `calendar.freeBusy` | | WhatsApp | `whatsapp.send`, `whatsapp.sendTemplate`, `whatsapp.sendInteractive`, `whatsapp.sendMedia`, `whatsapp.listTemplates`, `whatsapp.getConversation`, `whatsapp.getStatus` | ### Custom Tools Automations can execute any custom tool defined in the `tools/` directory. Custom tools are org-level resources stored in the `customTools` table — they do not need to be registered on any agent to be used in automations. The automation engine queries the `customTools` table directly by tool name and delegates execution to the tool executor service. ## Firing Triggers Manually The `triggers fire` command lets you manually fire a trigger for testing without waiting for a real data mutation or cron schedule. The trigger executes its actions exactly as it would in a real activation. ```bash struere triggers fire ``` ### Options | Flag | Description | |------|-------------| | `--env ` | Environment: `development` or `production`. Default: `development` | | `--entity ` | Entity ID to use as trigger context | | `--data ` | JSON data payload to pass as trigger context | | `--json` | Output the full JSON execution result | | `--confirm` | Skip the production confirmation prompt | | `--verbose` | Show detailed execution output including per-step timing | ### Examples ```bash # Fire a trigger in development struere triggers fire notify-on-session # Fire with entity context struere triggers fire notify-on-session --entity abc123 # Fire with custom data payload struere triggers fire weekly-report --data '{"weekStart": "2026-04-13"}' # Fire against production struere triggers fire notify-on-session --env production --confirm # Get full JSON result for debugging struere triggers fire notify-on-session --entity abc123 --json --verbose ``` The trigger runs as the **system actor** with full permissions, matching the behavior of real trigger activations. ## Debugging Triggers ### CLI Commands Use the `triggers` CLI command to inspect and debug automations: ```bash struere triggers list struere triggers logs [slug] struere triggers log struere triggers log --env production struere triggers log --nth 2 --verbose struere triggers log --verbose ``` ### Agent Step Transparency When a trigger uses `agent.chat`, the execution log shows agent health signals: - Number of LLM iterations - Tool call summary (successes and errors) - Warning when agent self-corrected after tool errors - Warning when agent hit max iteration limit (10) Use `--verbose` on `triggers log` or `triggers logs` to see the full tool call timeline within agent steps, including individual tool call durations and error details. ## Dashboard Management The dashboard provides automation management at `/triggers`: - View all configured automations - See automation run history with status - View scheduled (pending) runs - Retry failed runs - Cancel pending runs --- ## Events > Audit logging and event-driven architecture Source: https://docs.struere.dev/platform/events.md Events provide a complete audit trail of all mutations in the Struere platform. The platform automatically emits events whenever entities are created, updated, or deleted. Every mutation is recorded with full actor context, enabling compliance tracking, debugging, and event-driven automation. ## Event Structure Each event captures the full context of what happened, who did it, and when: | Field | Type | Description | |-------|------|-------------| | `organizationId` | ID | Owning organization | | `environment` | enum | `"development"` or `"production"` | | `eventType` | string | Category of the event (e.g., `"session.created"`, `"session.completed"`) | | `entityId` | string | ID of the affected entity (optional for non-entity events) | | `actorType` | string | `"user"`, `"agent"`, `"system"`, or `"webhook"` | | `actorId` | string | ID of the actor who performed the action | | `payload` | object | Event-specific data | | `timestamp` | number | Unix timestamp of when the event occurred | ## Event Types All events are system-generated. The platform automatically emits events when mutations occur. ### Entity Lifecycle Events | Event Type | Trigger | |------------|---------| | `{type}.created` | An entity of the given type is created (e.g., `session.created`, `teacher.created`) | | `{type}.updated` | An entity's data or status is modified | | `{type}.deleted` | An entity is soft-deleted | ### Payment Events | Event Type | Trigger | |------------|---------| | `payment.created` | A payment entity is created | | `payment.paid` | A payment is confirmed via webhook or reconciliation | | `payment.failed` | A payment is rejected or cancelled | | `payment.link_created` | A payment link is generated | ### Trigger Execution Events | Event Type | Trigger | |------------|---------| | `trigger.executed` | An automation completes successfully | | `trigger.failed` | An automation fails during execution | ## Event Sources Events are emitted automatically from all mutation sources: | Source | Description | |--------|-------------| | Dashboard CRUD | User actions in the admin dashboard | | Agent tool calls | Built-in tools like `entity.create`, `entity.update`, `entity.delete` | | API mutations | External API calls via HTTP endpoints | | Webhooks | External services like payment providers updating entity state | All sources capture the actor context, so events always record who performed the action. ## Environment Scoping Events are scoped to the environment where they were emitted. Development events are only visible in the development environment, and production events only in production. Events are indexed by `by_org_env_type` for efficient querying by organization, environment, and event type. ## Event Payloads Event payloads contain event-specific data. The payload typically includes the entity data at the time of the event: ### {type}.created Payload ```typescript { entityType: "session", data: { teacherId: "ent_abc123", studentId: "ent_def456", startTime: 1700000000000, status: "scheduled", }, } ``` ### {type}.updated Payload ```typescript { entityType: "session", changes: { status: "completed", teacherReport: "Good progress.", }, previousData: { status: "scheduled", }, } ``` ## Viewing Events Events are visible in the dashboard under the **Events** tab. You can filter by event type, entity, and time range to inspect the audit trail for any record. ## Events and Automations Events serve as the input for the automation system. When a data mutation emits an event, matching automations are activated: ``` Entity mutation occurs │ ▼ System event emitted ({type}.created, {type}.updated, etc.) │ ▼ Automation engine checks for matching automations │ ▼ Matching automations scheduled for execution │ ▼ Automation actions execute ``` Automations are defined using [`defineTrigger`](/sdk/define-trigger) and fire based on entity lifecycle events. See [Automations](/sdk/define-trigger) for details. --- ## Permissions > Role-based access control with row and column security Source: https://docs.struere.dev/platform/permissions.md Struere implements a full RBAC permission engine with deny-overrides-allow evaluation, row-level scope rules, column-level field masks, and per-tool permission controls. The permission system evaluates access on every tool call during agent execution, using an ActorContext that resolves roles eagerly at request time across 3 isolated environments. ## Architecture The permission engine lives in `platform/convex/lib/permissions/` and consists of five modules: | Module | File | Responsibility | |--------|------|----------------| | Context | `context.ts` | Build ActorContext with eager role resolution | | Evaluate | `evaluate.ts` | Policy evaluation with deny-overrides-allow | | Scope | `scope.ts` | Row-level security via scope filters | | Mask | `mask.ts` | Column-level security via field masks | | Tools | `tools.ts` | Tool permission checking and identity modes | ## ActorContext Every request begins by building an `ActorContext` that captures who is making the request and what they are allowed to do: ```typescript interface ActorContext { organizationId: Id<"organizations"> actorType: "user" | "agent" | "system" | "webhook" actorId: string roleIds: Id<"roles">[] isOrgAdmin?: boolean environment: "development" | "production" } ``` | Field | Description | |-------|-------------| | `organizationId` | The organization boundary for all data access | | `actorType` | Whether the caller is a user, agent, system process, or webhook | | `actorId` | Unique identifier for the specific actor | | `roleIds` | Pre-resolved role IDs (eager resolution at request start) | | `isOrgAdmin` | Whether the actor has organization admin privileges | | `environment` | Data environment scope (development or production) | ### Eager Resolution Roles are resolved once when the `ActorContext` is built, not on each permission check. This means the roles are fetched from the database at the start of the request and cached in the context object for the duration of that request. ### Context Builders | Function | Use Case | |----------|----------| | `buildActorContext()` | For authenticated user requests | | `buildSystemActorContext()` | For system operations (automations, webhooks) | | `buildActorContextForAgent()` | For agent execution with environment from API key | ## Permission Flow Every data operation passes through a four-stage pipeline: ``` Request arrives │ ▼ ┌──────────────────────────────────────┐ │ Stage 1: Build ActorContext │ │ │ │ Resolve organization, actor type, │ │ actor ID, environment, and role IDs │ │ (eager resolution). │ │ │ │ System actors are automatically │ │ allowed through all checks. │ └──────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ Stage 2: Policy Evaluation │ │ │ │ Find all policies matching the │ │ requested resource and action. │ │ │ │ Deny overrides allow: │ │ - Any deny policy → access denied │ │ - At least one allow → proceed │ │ - No matching policies → denied │ └──────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ Stage 3: Scope Rules (Row-Level) │ │ │ │ Apply scope filters to restrict │ │ which entities are visible. │ │ │ │ Example: Teacher sees only sessions │ │ where data.teacherId matches their │ │ user ID. │ └──────────────┬───────────────────────┘ │ ▼ ┌──────────────────────────────────────┐ │ Stage 4: Field Masks (Column-Level) │ │ │ │ Hide or redact specific fields │ │ from the response. │ │ │ │ Example: Teacher cannot see │ │ data.paymentId on sessions. │ └──────────────┬───────────────────────┘ │ ▼ Filtered response ``` ## Policy Evaluation Policies define what actions a role can perform on which resources. ### Action Types The platform supports 6 action types: | Action | Description | |--------|-------------| | `create` | Create a new entity | | `read` | Retrieve a single entity by ID | | `update` | Modify an existing entity | | `delete` | Soft-delete an entity | | `list` | Query multiple entities | | `manage` | Full management access to a resource (e.g., `integration:whatsapp`) | These actions apply to both data type resources and the built-in `users` resource for team management. ### Evaluation Rules 1. **Collect** all policies from the actor's roles that match the requested resource and action 2. **Deny overrides**: If any matching policy has `effect: "deny"`, access is denied regardless of allow policies 3. **Allow required**: At least one matching policy must have `effect: "allow"` for access to be granted 4. **No match = denied**: If no policies match the resource and action, access is denied > **Gotcha:** `PolicyConfig` has no `priority` field — deny always overrides allow automatically. See [Platform Gotchas](/platform/gotchas) for details. ### API ```typescript canPerform(ctx, actorContext, resource, action) ``` Returns a `PermissionResult`: ```typescript interface PermissionResult { allowed: boolean reason?: string matchedPolicy?: Id<"policies"> evaluatedPolicies?: number } ``` ```typescript assertCanPerform(ctx, actorContext, resource, action) ``` Throws a `PermissionError` if access is denied. Used in mutations where denial should halt execution. ### System Resources In addition to data type slugs, the `resource` field supports the built-in `users` resource for controlling team management access in the dashboard: ```typescript { resource: "users", actions: ["update", "delete"], effect: "allow" } ``` This grants the role permission to assign internal roles to team members and remove non-admin members from the organization. See [User Management](/platform/users) for details. ## Scope Rules (Row-Level Security) Scope rules restrict which entities an actor can see by filtering query results based on entity data fields. ### How Scope Rules Work When an actor queries entities, scope rules for their role and the target data type are collected. These rules generate filters that are applied to the query: ``` Actor queries "session" entities │ ▼ Scope rules for actor's roles + "session" data type collected │ ▼ Filters generated: { field: "data.teacherId", operator: "eq", value: } │ ▼ Query results filtered to only matching entities ``` ### Dynamic Value Resolution Scope rules support `actor.userId` as a dynamic value that resolves to the current actor's user ID at query time. This enables rules like "a teacher can only see sessions assigned to them": ```typescript { entityType: "session", field: "data.teacherId", operator: "eq", value: "actor.userId" } ``` ### Operators | Operator | Description | |----------|-------------| | `eq` | Field equals value | | `neq` | Field does not equal value | | `in` | Field is contained in value | | `contains` | Field contains value | ## Field Masks (Column-Level Security) Field masks control which fields an actor can see on an entity, implementing column-level security. ### Allowlist Strategy Field masks use an **allowlist strategy**: new fields added to a data type are hidden by default until explicitly allowed in a role's field mask configuration. This is a fail-safe design that prevents accidental data exposure. ### Mask Types | Type | Behavior | |------|----------| | `hide` | Removes the field entirely from the response | | `redact` | Defined in the schema but not implemented at runtime. Records with `maskType: "redact"` are accepted but behave the same as no mask (the field is returned unmodified). | ### Example A teacher role with field masks: ```typescript fieldMasks: [ { entityType: "session", fieldPath: "data.paymentId", maskType: "hide" }, { entityType: "student", fieldPath: "data.guardianId", maskType: "hide" }, ] ``` When a teacher queries a session, the `paymentId` field is not present in the response. ## Tool Permissions The permission engine also controls which tools an agent or user can invoke. ### Tool Identity Modes | Mode | Behavior | |------|----------| | `inherit` | Tool runs with the caller's permissions | | `system` | Tool runs with system-level permissions (environment-aware, `isOrgAdmin: true`) | | `configured` | Tool runs with explicitly configured permissions | ### Permission Check ```typescript canUseTool(ctx, actorContext, toolName) ``` Checks if the actor is allowed to use the specified tool based on their role's tool permissions. ## Agent Access (Conversation Filtering) Roles can define an `agentAccess` field — an array of agent slugs — that controls which conversations members can see in the dashboard. ### How It Works 1. When a member queries threads, the platform loads their role assignments 2. All `agentAccess` slugs across the member's roles are collected into a union set 3. Slugs are resolved to agent IDs via the `agents.by_org_slug` index 4. Only threads belonging to those agents are returned ### Access Rules | Actor | Behavior | |-------|----------| | Admin | Sees all conversations (bypasses `agentAccess`) | | Member with `agentAccess` | Sees only threads from listed agents | | Member without `agentAccess` | Sees no conversations | ### Conversation Permissions Members can only **view and reply** to conversations. Starting new conversations is restricted to admins. This is enforced at both the query level (thread filtering) and the mutation level (chat actions). ### Slug Resolution Agent slugs are resolved at query time against the `agents` table. If a slug doesn't match an existing agent, it is silently skipped. This means roles can reference agents before they are created — access is granted automatically when the agent is deployed. ## Security Properties The permission engine guarantees the following security properties: | Property | Description | |----------|-------------| | No privileged data paths | Templates, tools, and automations all go through the permission engine | | Defense in depth | Organization boundary checked at multiple layers | | Environment isolation | All queries, roles, configs, and entities scoped to environment | | Deny-safe | Any deny policy blocks access, regardless of allow policies | | Fail-safe | New fields hidden by default via allowlist field masking | | Audit trail | Events capture actor context for all mutations | ## System Actor The system actor bypasses all permission checks. It is used for: - Automation execution (automated workflows need full data access) - Webhook processing (inbound messages need to create entities) - Internal operations (migrations, system maintenance) The system actor is built using `buildSystemActorContext()` and always includes the environment. --- ## Agent Permissions > How roles grant capabilities to humans and agents through one permission engine Source: https://docs.struere.dev/platform/agent-permissions.md Struere has one permission engine. It evaluates the same way for human users and for agents — both are actors. Roles define capabilities. Actors inherit them. Granting a role to a person and granting the same role to an agent are two paths to the same outcome: the actor runs under that role's policies, scope rules, field masks, and tool permissions. This page explains the model end-to-end, including the field naming that often confuses people: `agentAccess` on a role and `roles` on an agent are unrelated and do different things. ## Roles Define Capabilities A role is the unit of capability. Every role document declares what an actor holding it can do, and which rows and fields they can see. | Field | Purpose | |-------|---------| | `policies` | Resource + action allow/deny rules. Deny overrides allow. | | `scopeRules` | Row-level filters. Restrict which entities the actor can see. | | `fieldMasks` | Column-level masks. Hide or redact specific fields. | | `toolPermissions` | Which tools the actor is allowed to invoke. | | `agentAccess` | Dashboard ACL. Which agents users with this role can chat with in the UI. | The first four fields define what the actor can do at runtime. `agentAccess` is a UI-only ACL — see [What `agentAccess` is for](#what-agentaccess-is-for) below. ## Granting Roles There are two ways a role becomes effective for an actor. ### Humans get roles via `userRoles` When a user is invited to or assigned within an organization, they are linked to one or more roles in the `userRoles` table. This happens through the dashboard or the invite flow. Once assigned, every dashboard request the user makes builds an `ActorContext` populated with their role IDs. ### Agents get roles via `defineAgent({ roles: [...] })` An agent declares its roles in code. The CLI syncs the declaration into `agentConfigs.roleSlugs`, which the runtime uses to populate the agent's `ActorContext` on every chat request. ```typescript import { defineAgent } from 'struere' export default defineAgent({ name: "Coach Stats", slug: "coach-stats", version: "0.1.0", systemPrompt: "You report stats for Team A players.", model: { model: "openai/gpt-5-mini" }, tools: ["entity.query"], roles: ["team-a-coach"], }) ``` The same `team-a-coach` role document defines what `coach-stats` (the agent) can do at runtime. If a human user is also assigned `team-a-coach` via `userRoles`, they get the same capabilities when querying through the dashboard. ## Runtime Evaluation When a permission-checked operation runs — a tool call like `entity.query`, an `entity.update`, or a dashboard query — the engine builds an `ActorContext` from the actor's roles and applies the same four-stage pipeline regardless of actor type: ``` Build ActorContext (organizationId, actorType, actorId, roleIds, environment) │ ▼ Policies canPerform / assertCanPerform — deny overrides allow │ ▼ Scope rules restrict which rows are returned │ ▼ Field masks hide or redact specific fields on returned rows │ ▼ Tool canUseTool — restrict which tools the actor may invoke ``` The pipeline is identical for `actorType: "user"` and `actorType: "agent"`. The role definition is the source of truth in both cases. For the full pipeline reference, see [Permissions](./permissions). ## Default Behavior If an agent has no `roles` declared in `defineAgent`, the runtime falls back to a singleton role named `agent`. This is the legacy zero-config behavior — existing agents continue to work without modification. The fallback `agent` role is created automatically and grants the broad permissions previously associated with agent execution. To narrow an agent's permissions, declare explicit roles in `defineAgent({ roles: [...] })`. As soon as `roles` is non-empty, the agent uses the union of those roles instead of the fallback. ## Worked Example A team of coaches each see only their own team's players. The `coach-stats` agent reports stats for Team A and inherits the same scoping. A second `league-stats` agent reports across all teams and uses no scoping. ### Define the role ```typescript import { defineRole } from 'struere' export default defineRole({ name: "team-a-coach", policies: [ { resource: "player", actions: ["list", "read"], effect: "allow" }, ], scopeRules: [ { entityType: "player", field: "data.teamId", operator: "eq", value: "team-A" }, ], }) ``` ### Define the scoped agent ```typescript import { defineAgent } from 'struere' export default defineAgent({ name: "Coach Stats", slug: "coach-stats", version: "0.1.0", systemPrompt: "List players on your team.", model: { model: "openai/gpt-5-mini" }, tools: ["entity.query"], roles: ["team-a-coach"], }) ``` When `coach-stats` calls `entity.query` for the `player` data type, the scope rule on `team-a-coach` is applied. Only players where `data.teamId` equals `"team-A"` are returned. Team B players are invisible to this agent — not filtered post-hoc, but excluded at the query layer. ### Define an unscoped agent for comparison ```typescript import { defineAgent } from 'struere' export default defineAgent({ name: "League Stats", slug: "league-stats", version: "0.1.0", systemPrompt: "Report player stats across the entire league.", model: { model: "openai/gpt-5-mini" }, tools: ["entity.query"], roles: ["league-analyst"], }) ``` Where `league-analyst` has a `player` allow policy and no scope rules. The same `entity.query` call from `league-stats` returns every player in the org. The difference is entirely in the role assignments — the tool, the data type, and the agent code are otherwise structured the same way. ## What `agentAccess` Is For `agentAccess` on a role is a dashboard ACL. It controls which agents users holding the role can open and chat with in the dashboard UI. It does not affect the agent's runtime permissions in any way. ```typescript defineRole({ name: "support-staff", policies: [ { resource: "ticket", actions: ["read", "update"], effect: "allow" }, ], agentAccess: ["support-agent", "billing-agent"], }) ``` A user assigned `support-staff` can open `support-agent` and `billing-agent` from the dashboard's chat surfaces. The `support-agent` itself runs under whatever roles its own `defineAgent({ roles: [...] })` declares. Listing an agent in `agentAccess` does not grant that agent any policies, scope rules, or field masks. The two relationships answer different questions: | Question | Field | |----------|-------| | Which humans can chat with this agent in the UI? | The role's `agentAccess` | | What can this agent do when it executes a tool call? | The agent's `roles` | You will often want both. A `coach` role can list `coach-stats` in `agentAccess` (so coaches can open the chat) while the `coach-stats` agent declares `roles: ["coach"]` (so it inherits the same capabilities the coach has when querying directly). Same role document, two relationships, one mental model: **roles are the unit of capability; both humans and agents inherit them**. --- ## Environment Isolation > Development, production, and eval data separation Source: https://docs.struere.dev/platform/environment-isolation.md Struere enforces strict isolation between environments. All data, permissions, and configurations are scoped to an environment, preventing accidental cross-environment data access. ## Environments The platform defines three environments: ```typescript type Environment = "development" | "production" | "eval" ``` | Environment | Purpose | |-------------|---------| | `development` | Active development — where `struere dev` syncs your agents, types, roles, and triggers | | `production` | Live traffic — promoted via `struere deploy` | | `eval` | Automated testing — receives eval suites and fixture data for running evals against a controlled dataset | Every request carries an environment context that is threaded through the entire execution chain: config lookup, actor context building, data queries, event logging, and tool execution. ## Scoping Rules Resources are either scoped per environment (isolated) or shared across environments: ### Environment-Scoped Resources | Table | Description | |-------|-------------| | `entityTypes` | Schema definitions can differ between dev and prod | | `entities` | All domain data is environment-isolated | | `entityRelations` | Relations between entities | | `roles` | Role definitions and their policies | | `agentConfigs` | Agent configurations (system prompt, model, tools) | | `threads` | Conversation threads | | `events` | Audit log events | | `executions` | Usage tracking records | | `triggers` | Automation rule definitions | | `triggerRuns` | Scheduled trigger execution records | | `apiKeys` | API keys carry an environment field | | `integrationConfigs` | External service configurations | | `whatsappConnections` | WhatsApp phone number connections | | `calendarConnections` | Google Calendar connections | | `emailMessages` | Email message records | | `sandboxSessions` | Studio sandbox sessions | | `fixtures` | Test fixture data | | `customTools` | Custom tool definitions | | `routers` | Router definitions | | `evalSuites` | Eval suite definitions | | `evalRuns` | Eval run records | ### Shared Resources | Table | Description | |-------|-------------| | `agents` | Agent identity (name, slug, description) is shared | | `users` | User records from Clerk | | `organizations` | Organization records | | `toolPermissions` | Tool permission configurations | The `agents` table is intentionally shared so that the same agent can have different configurations in development and production. The `agentConfigs` table stores the environment-specific configuration, looked up via the `by_agent_env` index. ## API Key Environment API keys carry an `environment` field that determines which environment the request operates in: ```typescript { organizationId: Id<"organizations">, environment: "development" | "production" | "eval", keyHash: string, name: string, } ``` When a chat request arrives with a Bearer token: 1. The API key is looked up by its SHA-256 hash 2. The `environment` field is extracted from the key 3. This environment is used for the entire request chain A development API key **cannot** access production data, and vice versa. This enforcement happens at the ActorContext level, where the environment is set once and used for all subsequent operations. ## Environment Threading The environment value flows through every layer of a request: ``` API Key → environment: "development" │ ▼ ActorContext.environment = "development" │ ├─► Agent config loaded via by_agent_env index (development config) ├─► Thread created/retrieved with environment = "development" ├─► Entity queries use by_org_env_type index ├─► Scope rules loaded for development roles ├─► Field masks loaded for development roles ├─► Events logged with environment = "development" └─► Execution metrics recorded with environment = "development" ``` ## CLI and Environments The CLI commands interact with specific environments: | Command | Environment | |---------|-------------| | `struere dev` | Syncs to **development** (agents, types, roles, triggers) and **eval** (agents, types, roles, eval suites, fixtures) | | `struere deploy` | Promotes all agents to **production** environment | During development, `struere dev` watches files and syncs changes to both the development and eval environments. Production data and configurations are not affected until `struere deploy` is explicitly run. The eval environment receives eval suites and fixture entities alongside the same schema definitions (types, roles) as development. ## Dashboard Environment Switching The dashboard supports environment switching via a URL query parameter. The `EnvironmentContext` provider reads the current environment from the URL and passes it to all data-fetching hooks. This allows admins to view and manage both development and production data from the same interface. ## Eval Environment The eval environment is purpose-built for automated testing. It mirrors the development schema (data types, roles, agent configs) but also receives: - **Eval suites** — test case definitions synced from `evals/*.eval.yaml` - **Fixture entities** — pre-defined test data synced from `fixtures/*.fixture.yaml` Triggers are **not** synced to the eval environment, preventing side effects during test runs. On every sync, all existing entities and relations in the eval environment are deleted and recreated from fixture definitions, ensuring a clean, known state. ## Migrations The platform includes backfill migrations (`platform/convex/migrations/addEnvironment.ts`) that set the `environment` field on pre-existing records. Each migration batch filters for `environment === undefined`, making them naturally idempotent. Records without an environment field are treated as needing migration. ## Best Practices **Use separate API keys for each environment.** Create a development key for testing and a production key for live traffic. Never use a development key in production systems. **Test triggers in development first.** Since triggers fire from all mutation sources, test automated workflows in development before deploying to production. **Review data type schemas before deploying.** Development and production data types can diverge. Use `struere status` to compare local definitions against remote state before deploying. --- ## Evaluations > Test agent behavior with automated assertions and LLM-as-judge scoring Source: https://docs.struere.dev/platform/evals.md Struere provides a built-in evaluation framework for testing agent behavior before deployment, with eval suites, test cases, automated runs, and result tracking. Evals run in a dedicated eval environment isolated from development and production data, ensuring safe testing without affecting live systems. ## How It Works ``` evals/*.eval.yaml struere dev Dashboard / CLI (define) ──────► (sync) ──────► (run & review) │ ▼ Agent executes each turn, then assertions evaluate the responses ``` 1. You define eval suites as YAML files in `evals/` 2. `struere dev` syncs them to Convex (like agents and data types) 3. Trigger runs from the dashboard or CLI (`struere eval run `) 4. Each case plays out a multi-turn conversation, then assertions evaluate the agent's responses 5. Results are persisted with full conversation history, tool calls, and scores ## YAML Format ```yaml suite: "Customer Support Tests" slug: "customer-support-tests" agent: "support" description: "Verify the support agent handles common requests correctly" tags: ["regression", "tools"] judgeModel: "claude-haiku-4-5-20251001" judgePrompt: "Be strict on factual accuracy but lenient on phrasing." cases: - name: "Greeting test" description: "Agent introduces itself" turns: - user: "Hello, who are you?" assertions: - type: llm_judge criteria: "Response is polite, introduces itself, and offers to help" weight: 3 - type: contains value: "help" - name: "Tool usage test" turns: - user: "Show me all customers" assertions: - type: tool_called value: "entity.query" - type: tool_not_called value: "entity.delete" - name: "Multi-turn context" turns: - user: "My name is Alex" assertions: - type: llm_judge criteria: "Acknowledges the name" - user: "What is my name?" assertions: - type: contains value: "Alex" finalAssertions: - type: llm_judge criteria: "Agent maintained context across the entire conversation" ``` ## Suite Configuration | Field | Required | Description | |-------|----------|-------------| | `suite` | Yes | Display name | | `slug` | Yes | Unique identifier (used for sync) | | `agent` | Yes | Agent slug to test (must exist in `agents/`) | | `description` | No | What this suite tests | | `tags` | No | Tags for filtering and organization | | `judgeModel` | No | LLM model for `llm_judge` assertions (default: `grok-4-1-fast`) | | `judgePrompt` | No | Custom instructions for the judge LLM (e.g., strictness level, focus areas) | | `judgeContext` | No | Reference data or ground-truth information provided to the judge | ## Cases and Turns Each case defines a multi-turn conversation to test: ```yaml cases: - name: "Refund request flow" description: "Agent should look up order, then process refund" turns: - user: "I need a refund for order #123" assertions: - type: tool_called value: "entity.query" - type: llm_judge criteria: "Agent acknowledges the request and looks up the order" - user: "Yes, please process it" assertions: - type: tool_called value: "entity.update" - type: llm_judge criteria: "Agent confirms the refund was processed" finalAssertions: - type: llm_judge criteria: "Agent handled the complete refund flow professionally" weight: 5 ``` **Turn fields:** | Field | Required | Description | |-------|----------|-------------| | `user` | Yes | The user message sent to the agent | | `assertions` | No | Assertions evaluated after the agent responds to this turn | **Case fields:** | Field | Required | Description | |-------|----------|-------------| | `name` | Yes | Display name | | `description` | No | What this case tests | | `tags` | No | Tags for filtering | | `turns` | Yes | Ordered list of user messages and per-turn assertions | | `finalAssertions` | No | Assertions evaluated after all turns complete (evaluate overall conversation) | | `channel` | No | Thread channel: `widget`, `whatsapp`, `api`, or `dashboard` | | `contextParams` | No | Key-value pairs passed as thread context parameters | ## Thread Context Agents that use `{{threadContext.channel}}` or `{{threadContext.params.*}}` in their system prompts need `channel` and `contextParams` set on the eval case. Without them, these template variables resolve empty during eval runs. Both fields are **case-level** (not per-turn), matching real usage where a conversation always happens on a single channel with fixed context parameters. ```yaml cases: - name: "WhatsApp booking flow" channel: whatsapp contextParams: guardianPhone: "+56912345678" studentName: "Mateo" turns: - user: "I want to book a session for tomorrow" assertions: - type: tool_called value: "entity.query" - name: "Widget general inquiry" channel: widget turns: - user: "What services do you offer?" assertions: - type: llm_judge criteria: "Response lists available services" ``` When a case has `channel` or `contextParams`: - The eval thread is created with the specified channel and params - The agent's system prompt resolves `{{threadContext.channel}}` and `{{threadContext.params.*}}` correctly - The judge also sees the resolved system prompt for accurate evaluation See [System Prompt Templates](/tools/system-prompt-templates) for the full list of template variables. ## Assertion Types ### `llm_judge` — LLM Evaluates Response The most flexible assertion. An LLM judge evaluates the agent's response against your criteria and returns a score from 1-5. ```yaml - type: llm_judge criteria: "Response mentions the order status and expected delivery date" weight: 3 ``` The judge scores on a 1-5 scale: - **5**: Fully meets criteria - **4**: Mostly meets criteria - **3**: Partially meets criteria (pass threshold) - **2**: Mostly fails criteria - **1**: Completely fails criteria A score of 3 or higher counts as **passed**. The judge uses temperature 0 for deterministic results. The `judgePrompt` at the suite level customizes judge behavior. For example, you can make the judge strict for safety tests or lenient for creative responses. The `judgeContext` field provides reference data to the judge — useful for fact-checking against ground truth. ### `contains` — Substring Check Checks if the response contains a specific substring (case-insensitive). ```yaml - type: contains value: "refund" ``` ### `matches` — Regex Pattern Checks if the response matches a regex pattern (case-insensitive). ```yaml - type: matches value: "order #\\d+" ``` ### `tool_called` — Tool Was Used Verifies that the agent called a specific tool during this turn. ```yaml - type: tool_called value: "entity.query" ``` ### `tool_not_called` — Tool Was Not Used Verifies that a specific tool was NOT called (useful for testing guardrails). ```yaml - type: tool_not_called value: "entity.delete" ``` ## Weights and Scoring Each assertion can have an optional `weight` (default: 1). Weights affect the overall score calculation: ```yaml assertions: - type: llm_judge criteria: "Critical safety check" weight: 5 # This assertion matters 5x more - type: contains value: "disclaimer" weight: 1 # Standard weight ``` **Scoring aggregation:** - **Per-case score**: Weighted average of all assertion scores (1-5 scale) - **Overall run score**: Average of all case scores - **Pass/fail**: A case passes only if ALL assertions pass ## Execution Model When you run an eval suite (from the dashboard or via `struere eval run`): 1. A **run** record is created with status `"running"` 2. Each case is executed **asynchronously** (cases run in parallel) 3. For each case, a new **thread** is created for the agent conversation 4. Each turn sends the user message to the agent and captures the full response including tool calls 5. Assertions are evaluated against the response 6. After all turns, `finalAssertions` are evaluated against the complete conversation 7. Results are persisted with full conversation history, tool call details, and scores **Rate limit handling:** If the LLM returns a 429 error, execution retries automatically (up to 5 times with 30-second delays). **Token tracking:** Both agent tokens and judge tokens are tracked per case and aggregated at the run level. ## Dashboard Features The eval dashboard provides: - **Suite list**: All suites for an agent with last run results and pass rates - **Run history**: Full history of runs with status, scores, duration, and token counts - **Case results**: Expandable view showing turn-by-turn conversation, tool calls, and assertion results - **Re-run failed**: Run only the cases that failed in a previous run - **Cancel**: Stop a running suite - **Export**: Copy results as Markdown ## Fixtures — Test Data for Evals Evals run in an isolated **eval** environment with its own data. Fixtures let you define a controlled, pre-known dataset that your evals run against, so test results are predictable and reproducible. ### Fixture YAML Format Create fixture files in `fixtures/` with the `.fixture.yaml` extension: ```yaml name: "Classroom Data" slug: "classroom-data" entities: - ref: "teacher-alice" type: "teacher" data: name: "Alice Smith" email: "alice@school.com" status: "active" - ref: "student-bob" type: "student" data: name: "Bob Jones" grade: 5 relations: - from: "teacher-alice" to: "student-bob" type: "teaches" metadata: since: "2024-01-01" ``` ### Fixture Fields | Field | Required | Description | |-------|----------|-------------| | `name` | Yes | Display name for the fixture set | | `slug` | Yes | Unique identifier (used for sync) | | `entities` | Yes | List of entities to create | | `relations` | No | List of relations between entities | **Entity fields:** | Field | Required | Description | |-------|----------|-------------| | `ref` | Yes | Local identifier used to resolve relations (not stored in DB) | | `type` | Yes | Data type slug (must match an existing data type) | | `data` | Yes | Free-form data matching the data type schema | | `status` | No | Entity status (defaults to `"active"`) | **Relation fields:** | Field | Required | Description | |-------|----------|-------------| | `from` | Yes | Source entity `ref` | | `to` | Yes | Target entity `ref` | | `type` | Yes | Relation type string | | `metadata` | No | Optional metadata for the relation | ### How Fixtures Work When `struere dev` runs, it makes two sync calls: 1. **Development** — agents, data types, roles, automations (your normal dev workflow) 2. **Eval** — agents, data types, roles, eval suites, and fixtures The eval environment mirrors your dev schema (types, roles, agent configs) but also receives fixture data and eval suites. Automations are **not** synced to eval to prevent side effects during test runs. On every sync, the eval environment is reset: all existing entities and relations are deleted, then recreated from fixture YAML. This guarantees a clean, known state for every eval run. ### Eval Execution Environment When you run an eval suite (from the dashboard or CLI), it executes in the **eval** environment. This means: - The agent sees fixture entities via `entity.query` - The agent can create and modify entities in eval without affecting dev or prod - All tool calls operate in the eval environment - Results are isolated from your real data ## CLI Commands ```bash # Scaffold a new eval suite struere add eval my-suite # Scaffold a new fixture struere add fixture classroom-data # Sync evals and fixtures (along with all other resources) struere dev # Run an eval suite from the CLI struere eval run my-suite # Run specific cases or filter by tag struere eval run my-suite --case "Greeting test" struere eval run my-suite --tag regression ``` The `struere add eval` command creates `evals/{slug}.eval.yaml` with a starter template. The `struere add fixture` command creates `fixtures/{slug}.fixture.yaml`. Edit the files, then `struere dev` syncs them. The `struere eval run` command syncs to the eval environment, executes the suite, and writes Markdown result files to `evals/runs/`. Each run creates a timestamped folder with a summary and per-case files prefixed with `PASS`, `FAIL`, or `ERROR`. The command exits with code 1 if any case failed, making it suitable for CI pipelines. See [struere eval run](/cli/eval) for full details. ## Writing Good Evals 1. **Be specific in `llm_judge` criteria.** "Response mentions the order status and delivery date" is better than "Good response" 2. **Use `contains`/`matches` for exact checks.** When you need a specific word or pattern, don't rely on the judge 3. **Use `tool_called`/`tool_not_called` for tool behavior.** Verify agents use the right tools and don't use dangerous ones 4. **Multi-turn tests catch context loss.** Test that the agent remembers information from earlier turns 5. **Use `weight` to prioritize critical assertions.** A weight-5 assertion matters 5x more than weight-1 6. **Use `finalAssertions` to evaluate overall conversation quality** after all turns complete 7. **Use `judgePrompt` to set the right strictness** per suite — strict for safety tests, lenient for creative tasks 8. **Use `judgeContext` for fact-checking** — provide ground-truth data the judge can verify against 9. **Keep cases focused.** Each case should test one specific behavior or flow 10. **Tag your suites and cases** for easy filtering (`tags: ["regression", "safety", "tools"]`) 11. **Set `channel` and `contextParams` when testing channel-specific behavior.** If your agent uses `{{threadContext.channel}}` or `{{threadContext.params.*}}`, these must be set on the case or the template variables resolve empty --- ## Studio > Browser-based AI coding environment with multi-provider model selection and custom API key support Source: https://docs.struere.dev/platform/studio.md Studio is a browser-based coding environment that runs an AI agent inside a sandboxed E2B cloud environment. It connects to your Struere project — pulling your agents, data types, roles, and triggers — so you can build and iterate without leaving the dashboard. ## How It Works ``` Dashboard (Studio Panel) | v POST /api/studio/sessions |-- Resolve API key (3-tier: direct → OpenRouter → platform) |-- Create sandbox session in Convex |-- Provision E2B sandbox |-- Write opencode.json (provider-specific config) |-- Install sandbox-agent + OpenCode |-- Pull project files (struere pull) |-- Start sandbox-agent server |-- Create ACP session | v User sends message → POST /api/studio/sessions/{id}/message |-- Forward to sandbox via ACP protocol |-- OpenCode processes with selected provider/model |-- Track token usage |-- Deduct credits (when using platform credits) | v Real-time events streamed via SSE to dashboard ``` Studio uses [OpenCode](https://opencode.ai) as the universal agent framework. OpenCode supports all major LLM providers through its `opencode.json` configuration, which Studio generates dynamically based on your provider and model selection. ## Providers and Models Studio supports four LLM providers. Each provider offers models grouped by tier: | Provider | Fast | Standard | Premium | |----------|------|----------|---------| | **xAI** | Grok Code Fast, Grok 4.1 Fast | Grok 4, Grok 3 | — | | **Anthropic** | Claude Haiku 4.5 | Claude Sonnet 4, Claude Sonnet 4.6 | Claude Opus 4.6 | | **OpenAI** | GPT-4.1 Mini, o4 Mini | GPT-4.1, GPT-5 | o3 | | **Google** | Gemini 2.5 Flash | Gemini 2.5 Pro | Gemini 3 Pro | The default is **xAI / Grok 4.1 Fast** (`grok-4-1-fast`). ## API Key Resolution Studio resolves API keys using the same 3-tier fallback as agent chat: 1. **Direct provider key** -- If the organization has a key configured for the selected provider in **Settings > Providers**, that key is used. No credits are consumed. 2. **OpenRouter key** -- If the organization has an OpenRouter API key configured, it is used. No credits are consumed. 3. **Platform credits** -- If no keys are found, the platform uses its own OpenRouter key and deducts credits from the organization balance. When using your own keys, token usage is tracked for analytics but no credits are deducted. Keys are resolved server-side and injected into the sandbox as environment variables — they never reach the browser. ## Configuration Before starting a session, the Studio config bar lets you choose: 1. **Provider** — xAI, Anthropic, OpenAI, or Google 2. **Model** — Filtered by selected provider, grouped by tier When you change the provider, the model resets to the first available model for that provider. API key resolution happens automatically based on your configured keys. Once a session starts, the configuration is locked and displayed as compact badges. You must stop the session to change settings. ## Session Lifecycle ``` [Config Bar: select provider/model] | v User sends first message (or clicks Start) | v provisioning → ready → active ←→ idle → stopped | v (auto-stop after 15 min idle) ``` | Status | Description | |--------|-------------| | `provisioning` | E2B sandbox is being created, dependencies installed | | `ready` | Sandbox is running, ACP session established | | `active` | Agent is processing a message | | `idle` | No activity, will auto-stop after idle timeout (default 15 minutes) | | `stopped` | Session ended (manual stop or idle timeout) | | `error` | Session failed to start or encountered a fatal error | ## OpenCode Configuration Studio generates a provider-specific `opencode.json` in the sandbox workspace. The configuration varies by provider: **xAI** — Uses the OpenAI-compatible endpoint at `api.x.ai/v1`: ```json { "provider": { "openai": { "options": { "baseURL": "https://api.x.ai/v1" }, "models": { "grok-4-1-fast": { "name": "grok-4-1-fast" } } } }, "model": "openai/grok-4-1-fast" } ``` **Anthropic** — Native Anthropic provider: ```json { "provider": { "anthropic": { "models": { "claude-sonnet-4": { "name": "claude-sonnet-4" } } } }, "model": "anthropic/claude-sonnet-4" } ``` **OpenAI** — Native OpenAI provider: ```json { "provider": { "openai": { "models": { "gpt-4.1": { "name": "gpt-4.1" } } } }, "model": "openai/gpt-4.1" } ``` **Google** — Native Google provider: ```json { "provider": { "google": { "models": { "gemini-2.5-flash": { "name": "gemini-2.5-flash" } } } }, "model": "google/gemini-2.5-flash" } ``` All configurations include `"instructions": ["CLAUDE.md"]` and permissions for web fetch and web search. ## Environment Variables Studio injects environment variables into the E2B sandbox. Only the env var for the selected provider is set: | Provider | Env Var in Sandbox | Source (Platform Mode) | |----------|-------------------|----------------------| | xAI | `OPENAI_API_KEY` | `XAI_API_KEY` | | Anthropic | `ANTHROPIC_API_KEY` | `ANTHROPIC_API_KEY` | | OpenAI | `OPENAI_API_KEY` | `OPENAI_API_KEY` | | Google | `GOOGLE_GENERATIVE_AI_API_KEY` | `GOOGLE_GENERATIVE_AI_API_KEY` | Additional env vars always set: - `STRUERE_API_KEY` — Temporary API key for the sandbox to call Convex - `STRUERE_CONVEX_URL` — Convex deployment URL - `OPENCODE_EXPERIMENTAL_OUTPUT_TOKEN_MAX` — Set to `32768` ## Billing Token usage is always tracked on the session for analytics (`totalInputTokens`, `totalOutputTokens`, `totalCreditsConsumed`). Credit deductions only occur when using platform credits (no direct or OpenRouter key configured): ``` processUsageEvent | |-- Always: update session token counters | |-- Platform credits: deduct credits via billing.deductCredits |-- Own key (direct or OpenRouter): skip credit deduction ``` Pricing is per-model and defined in `creditPricing.ts`. See [Limits & Pricing](../reference/limits) for details. ## Database Schema Studio sessions are stored in the `sandboxSessions` table: | Field | Type | Description | |-------|------|-------------| | `organizationId` | `Id<"organizations">` | Owning organization | | `environment` | `"development" \| "production"` | Environment scope | | `userId` | `Id<"users">` | User who created the session | | `status` | string | Session lifecycle status | | `sandboxProvider` | `"e2b"` | Always E2B | | `agentType` | `"opencode"` | Always OpenCode | | `model` | string (optional) | Selected model ID (e.g. `"grok-4-1-fast"`) | | `provider` | string (optional) | Selected provider (`"xai"`, `"anthropic"`, `"openai"`, `"google"`) | | `keySource` | string (optional) | Key source (`"platform"`, `"direct"`, `"openrouter"`) | | `totalInputTokens` | number | Cumulative input tokens | | `totalOutputTokens` | number | Cumulative output tokens | | `totalCreditsConsumed` | number | Cumulative credits (micro-USD) | ## API Routes | Route | Method | Description | |-------|--------|-------------| | `/api/studio/sessions` | POST | Create a new session. Body: `{ environment, provider, model }` | | `/api/studio/sessions/{id}/message` | POST | Send a message. Body: `{ message }` | | `/api/studio/sessions/{id}/keepalive` | POST | Reset idle timer | | `/api/studio/sessions/{id}` | DELETE | Stop and clean up session | ### Session Creation Flow 1. Resolve API key using 3-tier fallback (direct provider key → OpenRouter key → platform credits). 2. If using platform credits — check credit balance. Returns 402 if insufficient. 3. Create `sandboxSession` record in Convex. 4. Create temporary API key for sandbox ↔ Convex communication. 5. Provision E2B sandbox with provider-specific env vars. 6. Write project files (`struere.json`, `opencode.json`, `CLAUDE.md`, etc.). 7. Install sandbox-agent and OpenCode. 8. Run `struere pull` to sync project from Convex. 9. Start sandbox-agent server and create ACP session. 10. Update session status to `ready`. ### Message Flow 1. Validate session exists and is ready. 2. If using platform credits — check credit balance. 3. Forward message to sandbox via ACP `session/prompt`. 4. Record token usage asynchronously. 5. Deduct credits if using platform credits. ## Sandbox Contents After provisioning, the sandbox workspace at `/workspace` contains: ``` /workspace/ ├── struere.json # Org config (id, slug, name) ├── package.json # Project manifest ├── tsconfig.json # TypeScript config ├── opencode.json # Provider-specific OpenCode config ├── CLAUDE.md # Agent instructions ├── .env # STRUERE_API_KEY, STRUERE_CONVEX_URL ├── agents/ # Pulled from Convex (struere pull) ├── entity-types/ # Pulled from Convex ├── roles/ # Pulled from Convex └── triggers/ # Pulled from Convex ``` --- ## Dashboard > Navigate and use the Struere dashboard to manage your agents and data Source: https://docs.struere.dev/platform/dashboard.md The Struere dashboard is a Next.js application that provides a real-time interface for managing your agents, data, conversations, and platform settings. It connects to your Convex backend via real-time subscriptions, so all views update instantly when data changes. Authentication is handled by Clerk. ## Navigation The top header bar contains the primary navigation. The items shown depend on your role in the organization. ### Admin Navigation Organization admins see the full navigation set: | Tab | Route | Description | |-----|-------|-------------| | **Chats** | `/conversations` | Thread list, message history, WhatsApp management | | **Data** | `/entities` | Data type browser with record CRUD | | **Roles** | `/system/roles` | Role definitions, policies, scope rules, field masks | | **Tools** | `/system/tools` | Built-in and custom tool reference | | **Automations** | `/system/automations` | Trigger definitions and execution history | | **Routers** | `/system/routers` | Router definitions and routing rules | | **Settings** | `/system/settings` | Organization configuration | ### Member Navigation Non-admin members see a reduced navigation: | Tab | Route | Description | |-----|-------|-------------| | **Team** | `/team` | View organization members and manage team (permission-gated) | | **Data** | `/entities` | Data browser (filtered by permissions) | | **Chats** | `/conversations` | Conversations the member has access to | | **Profile** | `/profile` | Personal profile settings | ### Header Controls The right side of the header contains additional controls, some of which are admin-only: - **Docs** — Links to `docs.struere.dev` in a new tab. - **Studio** — Toggle the in-browser coding sandbox (admin only). See [Studio](./studio) for details. - **Environment Selector** — Switch between development and production (admin only). See the section below. - **Notifications** — Bell icon for sync and system notifications (admin only). - **Theme Toggle** — Switch between light and dark mode. - **User Button** — Clerk user menu for account settings and sign out. The header also includes an **Organization Switcher** and an **Agent Switcher** in the breadcrumb area. The org switcher lets you switch between organizations or create a new one. The agent switcher lets you jump directly to any agent's detail page. ## Environment Selector The environment selector in the header toggles between **Development** and **Production**. A green dot indicates production; a yellow dot indicates development. The selected environment is persisted to `localStorage` and affects every view in the dashboard. All environment-scoped data — agents configs, data types, records, roles, conversations, events, triggers, integrations, API keys — is filtered by the active environment. Switching environments gives you a completely separate view of your platform. Non-admin members are locked to the production environment. The selector is only visible to organization admins. ## Home Page The root route (`/`) displays an overview of your project in the current environment: **Agents** — A list of all agents in the organization. Each row shows the agent name, description, and an active status indicator. Clicking an agent navigates to its detail page. **Overview** — Summary stat cards linking to key sections: - Chats count (links to `/conversations`) - Data Types count (links to `/entities`) - Roles count (links to `/system/roles`) - Automations count (links to `/system/automations`) ### Member Home Page Members see a personalized home page with: - **My Roles** — Assigned roles with descriptions - **Quick Access** — Cards linking to Team, Data, Chats, and Profile - **Recent Conversations** — Latest threads the member has access to ## Agent Management ### Agent List The `/system/agents` route shows all agents in the organization. Create a new agent from `/system/agents/new`. ### Agent Detail Selecting an agent opens a detail view at `/system/agents/[agentId]` with a sidebar containing the following sections: | Section | Description | |---------|-------------| | **Overview** | Agent name, deployment status, chat URL, API endpoint, execution stats (total, success rate, avg duration, tokens), and recent execution history | | **Config** | Current agent configuration for the selected environment — system prompt, model, version, and tool list with parameter schemas. Includes a compiled system prompt preview | | **Tools** | All tools assigned to the agent, categorized as entity, event, or custom. Expandable to view parameter schemas and descriptions | | **Logs** | Execution log with status, duration, token usage, and timestamps | | **Evals** | Evaluation suites with test cases. Run evals from the dashboard, view results and scores per suite. Navigate into individual suites, cases, and run results | | **Settings** | Agent-level settings | A **Chat** toggle at the bottom of the sidebar opens a slide-out chat panel where you can send messages to the agent directly from the dashboard and see real-time responses with tool call visualization. The agent overview page displays two ready-to-use URLs: - **Chat UI** — A hosted chat interface for the agent (different URLs for development vs production) - **API Endpoint** — The REST endpoint at `/v1/agents/:slug/chat` for programmatic access ## Chats The `/conversations` route provides a split-pane view for managing all conversation threads. ### Thread List The left panel lists all threads in the current environment, showing: - Agent name - Participant name or phone number - Last message preview with timestamp - Channel badge indicating the source ### Channels Each thread is tagged with a channel indicating how the conversation was initiated: | Channel | Description | |---------|-------------| | **Widget** | Embedded chat widget on a website | | **WhatsApp** | WhatsApp conversation via Kapso integration | | **API** | Programmatic access via the Chat API | | **Dashboard** | Conversation started from the dashboard chat panel | ### Message View Selecting a thread opens the message history in the right panel. The view includes: - Full message history with role indicators (user, assistant, system) - Tool call and tool result visualization as collapsible bubbles - Timestamps and delivery status for WhatsApp messages (sent, delivered, read, failed) - A 24-hour messaging window indicator for WhatsApp threads - Reply input with support for text messages, WhatsApp templates, media attachments, and interactive messages ## Data The `/entities` route opens the data browser with a sidebar listing all data types in the current environment. The sidebar supports search filtering. ### Data Type View Selecting a data type from the sidebar loads a table view at `/entities/[type]` with: - Paginated record list with sortable columns - Search bar for full-text search across indexed fields - Status filter dropdown (active, inactive, pending, scheduled, confirmed, completed, cancelled, failed) - CSV export via clipboard - Create new record button ### Record Detail Clicking a record navigates to `/entities/[type]/[id]` which displays: - All record fields rendered from the data type schema - Current status with badge - Edit and delete actions - **Relations** — Linked records from other data types - **Timeline** — Event history for the record showing all system events (created, updated, deleted) and custom events Creating a new record at `/entities/[type]/new` renders a form generated from the data type's JSON Schema. ## Roles The `/system/roles` route (admin only) displays all roles defined in the current environment. Each role expands to show: - **Policies** — Allow or deny rules for resources and actions (create, read, update, delete, list) - **Scope Rules** — Row-level security filters showing which field, operator, and value restrict access - **Field Masks** — Column-level security showing which fields are visible or hidden per data type - **Assigned Users** — Team members currently assigned to the role For more on how permissions work, see [Permissions](./permissions). ## Tools The `/system/tools` route (admin only) provides a reference for all built-in tools available to agents, organized by category: - **Entity tools** — `entity.create`, `entity.get`, `entity.query`, `entity.update`, `entity.delete` - **Event tools** — `event.emit`, `event.query` - **Agent tools** — `agent.chat` Each tool entry shows its description, parameter schema with types, and required fields. For full tool documentation, see [Built-in Tools](../tools/built-in-tools). ## Automations The `/system/automations` route (admin only) lists all triggers in the current environment with: - Trigger name, slug, and description - Entity type and action that fires the trigger - Condition filter (if configured) - Actions to execute with tool name and arguments - Schedule configuration (delay, offset, cancel previous) - Retry settings (max attempts, backoff) - Enabled/disabled status - Last run status with timestamp - Execution history with success/failure counts, retry and cancel actions For more on triggers, see [Triggers](./triggers). ## Settings The `/system/settings` route (admin only) contains a sidebar with the following sections: ### General Organization name, slug, and your personal profile information (name, email). ### Users Team member management. View all users in the organization, change org-level roles (admin/member), assign platform roles to users, and invite new members. When a user is assigned a role that is bound to a data type, you can create a linked record for them. ### Integrations Connect external services to your platform. Each integration shows its current connection status: | Category | Integration | Description | |----------|-------------|-------------| | Communication | **WhatsApp** | AI-powered WhatsApp conversations via Kapso | | Communication | **Resend** | Transactional email from agents | | Calendar | **Google Calendar** | Calendar sync and availability checks | | Data | **Airtable** | Read and write Airtable records | | Payments | **Flow.cl** | Payment link generation | See [Integrations](../integrations/whatsapp) for setup guides. ### API Keys Create and manage API keys for programmatic access to the [Chat API](../api/chat). Keys are environment-scoped. You can create new keys, copy them, toggle visibility, and delete them. ### Providers Configure your own LLM provider API keys. Supported providers: | Provider | Models | |----------|--------| | **Anthropic** | Claude Haiku 4.5, Sonnet 4, Opus 4 | | **OpenAI** | GPT-5, GPT-5 Mini, GPT-5 Nano | | **Google AI** | Gemini 2.5 Pro, Gemini 2.5 Flash | | **xAI** | Grok 4 models | Each provider card lets you enter your API key, test the connection, and toggle between platform credits and your own key. Custom keys are used by [Studio](./studio) and agent execution. ### Billing View your credit balance, purchase credits via checkout, and review transaction history. Transactions show the source (agent execution, Studio session, eval run) and the credit amount in micro-USD. ### Usage Token consumption and execution statistics for the current environment: - Total executions, success rate, average duration, and total cost - Usage breakdown by agent - Usage breakdown by model - Eval run statistics - Recent execution log with per-execution details ### Danger Zone Permanently delete the organization and all associated data. Requires typing the organization name to confirm. This action deletes all agents, data, events, triggers, API keys, integrations, and team member access. ## Team The `/team` route is available to organization members and displays all users in the organization. It provides the same user management interface as the admin Settings > Users page, but with actions gated by the member's RBAC permissions. ### Permission-Gated Actions What a member can do on the Team page depends on their role's policies for the `users` resource: | Action | Required Policy | Description | |--------|----------------|-------------| | View team members | Default (no policy needed) | All members can see the list of organization users | | Change org roles | Admin only | Only organization admins can promote/demote users | | Assign internal roles | `resource: "users"`, `actions: ["update"]` | Change a member's RBAC role assignment | | Remove members | `resource: "users"`, `actions: ["delete"]` | Remove non-admin members from the organization | | Invite members | `resource: "users"`, `actions: ["create"]` | Send organization invitations via Clerk. Non-admins can only invite as `org:member` (not as admin). | Members cannot modify or remove admin users regardless of their permissions. ## Studio The Studio panel slides in from the right side of the dashboard when toggled from the header. It provides an in-browser coding sandbox powered by E2B and OpenCode. Studio is available to organization admins only. For full documentation on Studio, including provider selection, session lifecycle, and billing, see [Studio](./studio). ## Embed Widget Struere provides an embeddable chat widget that you can add to any website. The widget loads as a floating chat bubble and expands into a full chat interface. Add the widget to your site with a single script tag: ```html ``` Configuration options: | Parameter | Default | Description | |-----------|---------|-------------| | `org` | (required) | Organization slug | | `agent` | (required) | Agent slug | | `theme` | `dark` | `dark` or `light` | | `position` | `br` | Widget position: `br` (bottom-right), `bl` (bottom-left), `tr` (top-right), `tl` (top-left) | Any additional query parameters are passed through as `channelParams` on the thread, allowing you to attach contextual metadata (e.g., page URL, user ID) to widget conversations. The widget renders an iframe pointing to `/embed/[orgSlug]/[agentSlug]` and creates threads with the `widget` channel type. Widget conversations appear in the [Chats](#chats) view with a Widget channel badge. --- ## Billing and Credits > How credit-based billing works, pricing, and managing costs Source: https://docs.struere.dev/platform/billing.md Struere uses a credit-based billing system. Credits are consumed when no direct provider key or OpenRouter key is configured — the platform routes LLM calls through its own OpenRouter key and meters usage per-token at provider rates with a 25% markup. Organizations that bring their own API keys (direct provider keys or an OpenRouter key) bypass platform billing entirely. ## Credit System Credits are stored as **microdollars** (1 USD = 1,000,000 microdollars). This integer-based representation avoids floating-point precision issues across all balance calculations and transaction records. Each organization has a single `creditBalances` record tracking the current balance, plus a `creditTransactions` ledger that records every credit movement: | Transaction Type | Description | |------------------|-------------| | `purchase` | Credits added via Polar checkout | | `addition` | Manual credit addition by an org admin | | `deduction` | Automated deduction after an LLM call or billable action | | `adjustment` | Manual balance correction by an org admin | ## Purchasing Credits Credits are purchased through Polar checkout. The minimum purchase is **$1.00**. 1. Navigate to **Settings > Billing** in the dashboard 2. Enter the amount and click **Add Credits** 3. Complete checkout on Polar 4. Credits are added to your organization balance automatically via webhook Polar sends an `order.updated` webhook when a payment completes. The platform converts the Polar amount (cents) to microdollars and creates a `purchase` transaction. Duplicate orders are detected by `polarOrderId` to prevent double-crediting. ## Pricing All prices reflect provider base rates with a **25% platform markup**. Prices are per 1 million tokens. ### Anthropic | Model | Input / 1M tokens | Output / 1M tokens | |-------|--------------------|---------------------| | `claude-haiku-4-5` | $1.10 | $5.50 | | `claude-sonnet-4` | $3.30 | $16.50 | | `claude-sonnet-4-5` | $3.30 | $16.50 | | `claude-sonnet-4-6` | $3.30 | $16.50 | | `claude-opus-4` | $16.50 | $82.50 | | `claude-opus-4-5` | $5.50 | $27.50 | | `claude-opus-4-6` | $5.50 | $27.50 | ### OpenAI | Model | Input / 1M tokens | Output / 1M tokens | |-------|--------------------|---------------------| | `gpt-4o-mini` | $0.165 | $0.66 | | `gpt-4o` | $2.75 | $11.00 | | `gpt-4.1-nano` | $0.11 | $0.44 | | `gpt-4.1-mini` | $0.44 | $1.76 | | `gpt-4.1` | $2.20 | $8.80 | | `gpt-5-nano` | $0.055 | $0.44 | | `gpt-5-mini` | $0.275 | $2.20 | | `gpt-5` | $1.375 | $11.00 | | `gpt-5.1` | $1.375 | $11.00 | | `gpt-5.2` | $1.925 | $15.40 | | `o1` | $16.50 | $66.00 | | `o1-mini` | $1.21 | $4.84 | | `o1-pro` | $165.00 | $660.00 | | `o3` | $2.20 | $8.80 | | `o3-mini` | $1.21 | $4.84 | | `o3-pro` | $22.00 | $88.00 | | `o4-mini` | $1.21 | $4.84 | ### Google | Model | Input / 1M tokens | Output / 1M tokens | |-------|--------------------|---------------------| | `gemini-2.0-flash` | $0.11 | $0.44 | | `gemini-2.5-flash` | $0.33 | $2.75 | | `gemini-2.5-pro` | $1.375 | $11.00 | ### xAI | Model | Input / 1M tokens | Output / 1M tokens | |-------|--------------------|---------------------| | `grok-3` | $3.30 | $16.50 | | `grok-3-mini` | $0.33 | $0.55 | | `grok-4-0709` | $3.30 | $16.50 | | `grok-4-1-fast` | $0.22 | $0.55 | | `grok-4-1-fast-reasoning` | $0.22 | $0.55 | | `grok-4-1-fast-non-reasoning` | $0.22 | $0.55 | | `grok-4-fast-reasoning` | $0.22 | $0.55 | | `grok-4-fast-non-reasoning` | $0.22 | $0.55 | | `grok-code-fast-1` | $0.22 | $1.65 | The default model is `openai/gpt-5-mini` at $0.275 input / $2.20 output per million tokens. Models not in the pricing table fall back to the default model pricing. See [Model Configuration](/reference/model-configuration) for provider setup and model selection details. ## Credit Billing Flow Credits are deducted directly after each LLM execution completes. The flow is: ``` Step 1: LLM execution │ │ Run the agent's LLM loop (up to 10 iterations │ of tool calls). Token usage is tracked. │ ▼ Step 2: Deduct credits (atomic Convex mutation) │ │ After the LLM returns, calculate the actual cost │ from real token counts. Call deductCredits to │ subtract the cost from the organization's balance │ and insert a deduction transaction. │ ▼ Step 3: Reconciliation (every 1 hour) │ │ A cron job processes pending transactions and │ updates the cached balance on the creditBalances │ record. │ ▼ Balance is up to date ``` ### Cost Calculation The cost is calculated from actual token usage after the LLM call completes: ``` cost = (inputTokens * inputRate + outputTokens * outputRate) / 1,000,000 ``` The result is stored in microdollars on the execution record and deducted from the organization balance via `deductCredits`. ## What Consumes Credits ### Agent Chat (API, Webhook, Widget) Every agent chat request that uses platform credits (no direct or OpenRouter key configured) is billed. The cost is calculated from actual token usage: ``` cost = (inputTokens * inputRate + outputTokens * outputRate) / 1,000,000 ``` The result is stored in microdollars on the execution record and deducted from the organization balance. ### Studio Sessions [Studio](/platform/studio) sessions deduct credits per message when using platform credits. Each message's token usage is tracked on the session and deducted via the same billing pipeline. Sessions using a direct provider key or OpenRouter key track token usage for analytics but skip credit deduction. ### Eval Runs [Evaluations](/platform/evals) consume credits in two ways: - **Agent tokens** from running each eval case through the agent - **Judge tokens** from the LLM judge evaluating assertions Both agent and judge token costs are calculated and deducted through the standard billing pipeline. ### WhatsApp Messages Outbound WhatsApp messages sent through the platform incur per-message costs based on the Meta pricing category. Credits are deducted when the message status transitions to `sent`. ### Email Outbound emails sent through the platform incur a per-message credit deduction. ## Bring Your Own Keys Organizations can configure their own API keys to bypass platform billing entirely. ### How It Works 1. Navigate to **Settings > Providers** in the dashboard 2. Add a **direct provider key** (e.g., Anthropic, OpenAI, Google, xAI) or an **OpenRouter key** 3. Click **Test Connection** to verify the key works When an agent runs, the platform resolves the API key using a 3-tier fallback: 1. **Direct provider key** -- If a key is configured for the model's provider, that key is used. No credits consumed. 2. **OpenRouter key** -- If an OpenRouter key is configured, all LLM calls are routed through OpenRouter. No credits consumed. 3. **Platform credits** -- If no keys are found, the platform uses its own OpenRouter key and deducts credits. Token usage is always tracked on the execution record regardless of which key is used. Keys are stored encrypted in the `providerConfigs` table. The API never returns the full key — only a masked version (first 4 and last 4 characters). ## Manual Credit Management Organization admins can manage credits directly from the dashboard: ### Add Credits Add a specific amount of credits to the organization balance. Creates an `addition` transaction with the admin's user ID recorded as `createdBy`. ### Adjust Balance Set the balance to an exact value. The platform calculates the difference and creates an `adjustment` transaction. This is useful for corrections or promotional credits. Both operations require organization admin privileges and are recorded in the transaction ledger with full audit trail. ## Database Schema ### creditBalances | Field | Type | Description | |-------|------|-------------| | `organizationId` | ID | One balance record per organization | | `balance` | number | Current balance in microdollars | | `reservedCredits` | number (optional) | Sum of all active reservations in microdollars | | `updatedAt` | number | Last reconciliation timestamp | ### creditTransactions | Field | Type | Description | |-------|------|-------------| | `organizationId` | ID | Owning organization | | `type` | enum | `purchase`, `addition`, `deduction`, `adjustment` | | `amount` | number | Transaction amount in microdollars | | `balanceAfter` | number (optional) | Balance after reconciliation (undefined while pending) | | `description` | string | Human-readable description | | `executionId` | ID (optional) | Linked execution for deductions | | `createdBy` | ID (optional) | User who initiated manual transactions | | `metadata` | object (optional) | Additional data (e.g., `polarOrderId`) | | `createdAt` | number | Transaction timestamp | --- ## Routers > Route conversations between multiple agents on a single channel Source: https://docs.struere.dev/platform/routers.md Routers direct incoming conversations to the right agent using rules-based matching or LLM-powered classification, with per-message routing, configurable inactivity resets, and transfer limits. Struere routers support multi-agent orchestration with a chain depth limit of 3, automatic cycle detection, and shared conversation context across child threads. ## How Routers Work Routers re-evaluate every inbound message. When a message arrives on a channel with a router assigned, the routing engine picks an agent and routes the message: ``` Inbound message │ ▼ Router evaluates (rules or classify) │ ├─ Rules mode: evaluate conditions top-to-bottom └─ Classify mode: LLM intent classification │ ▼ Pick agent (or fallback) │ ▼ If different from currentAgentId → patch currentAgentId, increment transferCount, fire router.transferred │ ▼ Route message to chosen agent ``` ### Routing Characteristics | Property | Behavior | |----------|----------| | Per-message | Router re-evaluates on every inbound message | | currentAgentId | Reflects the most recent routing decision (used by UI, `router.transfer` tool, inactivity reset job) | | transferCount | Increments only when the chosen agent differs from `currentAgentId` | | router.transferred event | Fires only on actual agent switches | | Transfer | Agents can hand off via the `router.transfer` tool | | Transfer cap | Maximum transfers per conversation (default: 5) | | Inactivity reset | `currentAgentId` clears after configurable inactivity period (defaults to 4 hours) | | Loop prevention | An agent cannot transfer to itself (self-transfer blocked) | | Mutex | Only one `router.transfer` tool call runs per conversation at a time | ## Routing Modes ### Rules Mode Rules mode evaluates conditions with zero LLM cost. Rules are checked top-to-bottom, and the first matching rule wins: ```typescript { mode: "rules", rules: [ { conditions: [ { field: "customer.plan", operator: "in", value: ["enterprise"] }, ], route: "vip-agent", }, { conditions: [ { field: "customer.type", operator: "eq", value: "lead" }, ], route: "sales-agent", }, ], fallback: "general-agent", } ``` Multiple conditions within a rule use AND logic — all must match. If no rule matches, the `fallback` agent handles the conversation. Rules mode is ideal for deterministic routing based on known data: phone numbers, entity attributes, time of day, or message type. Entity fields use a `{entityType}.*` prefix — the routing engine extracts the entity type slug from the prefix and queries that entity type by the sender's phone number (e.g., `customer.plan` queries the `customer` entity type, `teacher.subject` queries the `teacher` entity type). When a message is routed via rules (e.g., `teacher.name exists`), the router has already identified the sender by matching their phone number against an entity. The matched agent can then use `{{threadContext.params.phoneNumber}}` in its system prompt or access `context.channelParams.phoneNumber` in a custom tool to load that entity's data directly. ### Classify Mode Classify mode uses an LLM to determine intent from the conversation context. The router sends the agent descriptions and recent messages to the classification model, which selects the best agent: ```typescript { mode: "classify", classifyModel: "openai/gpt-5-mini", contextMessages: 5, fallback: "support-agent", } ``` The `description` field on each agent reference is critical — it provides the LLM with context to make accurate routing decisions. Classify mode is ideal when routing depends on message intent rather than structured data. ## Handoff via router.transfer Agents can transfer a conversation to another agent using the `router.transfer` tool. This is useful when a conversation changes topic or the agent determines a different specialist should handle it: ```typescript export default defineAgent({ name: "Support Agent", slug: "support-agent", tools: ["entity.query", "router.transfer"], systemPrompt: `You handle technical support. If the customer asks about billing, transfer to billing-agent.`, }) ``` When `router.transfer` is called, the current agent's turn ends and the conversation is handed to the target agent. The target agent receives the conversation history for context. ### Transfer Restrictions | Restriction | Description | |-------------|-------------| | Must be in router | `router.transfer` only works for agents registered in a router | | Valid target | Target agent must be in the same router's agent list | | Transfer cap | Transfers stop after `maxTransfers` is reached (default: 5) | | Loop prevention | Cannot transfer to yourself (self-transfer blocked) | ## Per-Message Routing The router runs on every inbound message. The chosen agent handles that message; if it differs from the current `currentAgentId`, the thread's `currentAgentId` is updated, `transferCount` is incremented, and a `router.transferred` event fires. If the router picks the same agent that already owns the thread, none of those happen — the message just routes to that agent. `currentAgentId` reflects the most recent routing decision. It is used by the dashboard UI to display which agent owns the thread, by the `router.transfer` tool's same-agent-rejection check, and by the inactivity reset job. For `rules` mode, this means rules with content-based conditions (e.g. `message.text contains '...'`) fire on the matching message, not just on the first message of the conversation. For `classify` mode, this means an LLM classification call runs on every inbound message. The default classify model `openai/gpt-5-mini` is sub-second and sub-cent per call, but choose `rules` mode when deterministic per-message routing is sufficient. The `inactivityResetMs` option clears `currentAgentId` after a period of inactivity. The next inbound message routes from a clean state — no prior agent to compare against, so the routing decision will not increment `transferCount`. Defaults to 4 hours (`14400000` ms) if not configured. ```typescript { inactivityResetMs: 1800000, } ``` ## Safety Mechanisms ### Transfer Cap The `maxTransfers` field limits the number of agent-to-agent transfers per conversation. Once the cap is reached, `router.transfer` calls are rejected and the current agent continues handling the conversation: ```typescript { maxTransfers: 3, } ``` This prevents runaway transfer loops where agents keep handing off to each other. ### Loop Prevention The router prevents self-transfers. An agent cannot transfer to itself. Transfers to other agents (including the one that previously transferred) are allowed. ### Mutex Only one `router.transfer` tool call can execute per conversation at a time. If two transfer calls happen simultaneously, the second waits for the first to complete. The mutex applies only to the `router.transfer` tool — the per-message router evaluation runs outside it. ## Environment Scoping Routers are environment-scoped. A router defined in `development` only routes conversations in the development environment. The `struere dev` command syncs routers to the development and eval environments, and `struere deploy` pushes them to production. ## Assigning a Router to a Voice Connection In the dashboard, navigate to **Integrations > Voice** and select a Twilio phone number. Assign a router to handle inbound calls. The router's `voiceConfig` determines voice settings (model, voice, turn detection, auditor agent). See [Voice integration](/integrations/voice) for configuration details. ## Assigning a Router to a WhatsApp Connection In the dashboard, navigate to **Integrations > WhatsApp** and select a phone number connection. Instead of assigning a single agent, select a router. All incoming messages on that number will flow through the router. A WhatsApp connection can be assigned either a single agent or a router, but not both. ## Example: Internal Team vs Customers A single WhatsApp number shared between internal team members and external customers: ```typescript import { defineRouter } from 'struere' export default defineRouter({ name: "Shared Number Router", slug: "shared-number-router", description: "Routes internal team to ops agent, customers to support agent", mode: "rules", agents: [ { slug: "ops-agent", description: "Internal operations assistant for the team" }, { slug: "customer-agent", description: "Customer-facing support agent" }, ], rules: [ { conditions: [ { field: "staff.type", operator: "eq", value: "employee" }, ], route: "ops-agent", }, ], fallback: "customer-agent", maxTransfers: 5, inactivityResetMs: 3600000, }) ``` Internal team members (entities of type `staff` with `type: "employee"`) are routed to the ops agent. All other messages go to the customer support agent. After 1 hour of inactivity, `currentAgentId` clears. --- ## User Management > Managing users, roles, and organization membership Source: https://docs.struere.dev/platform/users.md Struere manages users and organizations through Clerk. User records, organization membership, and org-level roles are synced automatically via webhooks. Internal RBAC roles are assigned separately and control what data each user can access. ## User Model Users are synced from Clerk and stored in the `users` table. Each user record contains: | Field | Type | Description | |-------|------|-------------| | `email` | string | Primary email address | | `name` | string | Display name (optional) | | `clerkUserId` | string | Clerk's unique user identifier | | `createdAt` | number | Creation timestamp | | `updatedAt` | number | Last update timestamp | Users are created or updated automatically when Clerk fires `user.created` or `user.updated` webhook events. The `getOrCreateFromClerkNoOrg` internal mutation handles this upsert using the `by_clerk_user` index. ## Organizations Organizations are the top-level boundary for all data in Struere. Every entity, role, agent, thread, and configuration belongs to exactly one organization. | Field | Type | Description | |-------|------|-------------| | `name` | string | Organization display name | | `slug` | string | Unique URL-friendly identifier | | `clerkOrgId` | string | Clerk's organization identifier | Organizations are created via Clerk and synced through the `organization.created` webhook event. If a slug collision occurs, the platform appends a numeric suffix (e.g., `my-org-1`). ## Organization Membership Users belong to organizations through the `userOrganizations` join table. Each membership has a role: | Organization Role | Description | |-------------------|-------------| | `admin` | Full access to all data and settings. Bypasses the RBAC permission engine. Cannot be assigned internal roles. | | `member` | Access determined by their assigned internal RBAC role. No access to data without a role assignment. | Membership records are indexed by `by_user_org` for fast lookups and by `by_org` for listing all members. ### Admin vs Member Organization admins have unrestricted access. The permission engine short-circuits for admins, granting full read/write access to all data types in all environments. Because of this, admins cannot be assigned internal RBAC roles. Promoting a member to admin automatically deletes all their `userRoles` records. Members have no data access by default. They must be assigned an internal role (from the `roles` table) to gain permissions. The assigned role's policies, scope rules, and field masks determine exactly what data they can see and modify. The role's `agentAccess` field controls which agents' conversations are visible in the dashboard — members can only view and reply to threads from agents listed in their role. Members cannot start new conversations. All members have access to the Team tab in the dashboard where they can view all organization members. Write actions on the Team page — such as assigning roles to other members or removing members — require `resource: "users"` policies on the member's role. ### Last Admin Protection The platform prevents demoting the last admin to member. If only one admin remains, attempting to change their organization role to `member` throws an error. This ensures the organization always has at least one admin. ## Internal RBAC Roles Internal roles are defined in the `roles` table and are environment-scoped. They carry policies, scope rules, and field masks that control data access through the [permission engine](/platform/permissions). Role assignment is one-to-one: each member can have exactly one active internal role. Assigning a new role replaces the previous one by deleting all existing `userRoles` records for that user first. ### Assigning Roles Roles are assigned via `roles.assignToUser`, which enforces the following: 1. The target user must be a member of the organization 2. The target user must not be an admin (admins have full access and cannot hold internal roles) 3. Any existing role assignment is removed before the new one is created 4. The assignment tracks who granted it (`grantedBy`) and supports optional expiration (`expiresAt`) Non-admin members whose role includes `update` permission on the `users` resource can also assign roles to other members from the Team page. Admin-only restrictions still apply: non-admin members cannot modify admin users or promote any user to admin. ### userRoles Schema | Field | Type | Description | |-------|------|-------------| | `userId` | ID | Reference to the user | | `roleId` | ID | Reference to the role | | `grantedBy` | ID | User who assigned the role | | `expiresAt` | number | Optional expiration timestamp | | `createdAt` | number | Assignment timestamp | Expired role assignments are filtered out at query time. The `getUserRoles` query returns only active (non-expired) roles. ### Removing Roles `roles.removeFromUser` deletes all `userRoles` records for a given user, effectively revoking their data access. Deleting a role from the `roles` table is blocked if any users are still assigned to it. ## Pending Role Assignments Pending role assignments let you pre-assign a role to a user before they accept an organization invite. This is useful when you want a new team member to have the correct permissions from the moment they join. ### How It Works 1. An admin creates a pending assignment with an email address, a role, and an environment 2. The assignment is stored in the `pendingRoleAssignments` table, keyed by `organizationId` and `email` 3. When the user accepts the Clerk invitation and their membership is synced, the `syncMembership` mutation checks for a pending assignment matching their email 4. If found, the role is automatically assigned and the pending record is deleted ### pendingRoleAssignments Schema | Field | Type | Description | |-------|------|-------------| | `organizationId` | ID | Owning organization | | `email` | string | Normalized (lowercase, trimmed) email of the invitee | | `roleId` | ID | Role to assign on join | | `environment` | enum | Environment the role belongs to | | `createdBy` | ID | Admin who created the assignment | If a pending assignment already exists for the same email in the same organization, updating it replaces the role and environment rather than creating a duplicate. ### Validation The `createPendingAssignment` mutation enforces: - The role must belong to the same organization - The role's environment must match the provided environment - System roles cannot be pre-assigned ## Role-Bound Entity Types Data types can be bound to a role using the `boundToRole` and `userIdField` fields on the entity type. When a user is assigned a role that matches an entity type's `boundToRole` value, a new entity is automatically created for that user. ### How It Works 1. A data type defines `boundToRole: "support-agent"` and optionally `userIdField: "userId"` 2. A user joins the organization and their pending role assignment resolves to the `support-agent` role 3. The `syncMembership` mutation finds the matching entity type via the `by_org_env` index and the `boundToRole` filter 4. A new entity is created with the user's Clerk ID in the `userIdField`, plus their `name` and `email` if those fields exist in the schema This automates user provisioning. When a new support agent joins the organization, their agent profile entity is created without manual intervention. ### Example Given a data type defined as: ```typescript import { defineData } from 'struere' export default defineData({ name: "Support Agent", slug: "support-agent", schema: { type: "object", properties: { name: { type: "string" }, email: { type: "string" }, userId: { type: "string" }, department: { type: "string" }, skills: { type: "array", items: { type: "string" } }, }, }, boundToRole: "support-agent", userIdField: "userId", }) ``` When a user with email `alice@example.com` joins and is assigned the `support-agent` role, the platform creates: ```json { "entityTypeId": "", "status": "active", "data": { "userId": "clerk_user_abc123", "name": "Alice Smith", "email": "alice@example.com" } } ``` ## Team Management Permissions Members can manage team members from the Team tab in the dashboard if their role includes policies for the `users` resource. The `users` resource supports the standard CRUD actions: | Action | Permission | Capability | |--------|-----------|------------| | View members | Default | All members can view the team list | | Assign roles | `update` on `users` | Change internal role assignments for non-admin members | | Remove members | `delete` on `users` | Remove non-admin members from the organization | | Invite members | `create` on `users` | Send organization invitations. Non-admins can only invite as `org:member`. | ### Security Guards Regardless of permissions, non-admin members cannot: - Promote any user to organization admin - Modify or remove users who are organization admins - Modify their own organization role Example role with team management access: ```typescript export default defineRole({ name: "team-lead", description: "Can manage team members", policies: [ { resource: "users", actions: ["create", "update"], effect: "allow" }, { resource: "session", actions: ["list", "read"], effect: "allow" }, ], agentAccess: ["support-agent"], }) ``` ## Removing Users Removing a user from an organization deletes their `userOrganizations` membership record. The `users.remove` mutation handles this. The user's `users` record is not deleted since they may belong to other organizations. Non-admin members with `delete` permission on the `users` resource can also remove non-admin members via the Team page. When a membership is removed via Clerk (the `organizationMembership.deleted` webhook event), the `removeMembership` internal mutation also deletes all `userRoles` records for that user, revoking their internal role assignments. ## Authentication Flow Every authenticated request passes through the auth module at `platform/convex/lib/auth.ts`. ### AuthContext The `AuthContext` interface represents an authenticated caller: ```typescript interface AuthContext { userId: Id<"users"> organizationId: Id<"organizations"> clerkUserId: string actorType: "user" | "agent" | "system" | "webhook" } ``` ### getAuthContext Resolves the current user and organization from the Clerk JWT token: 1. Extract the Clerk user identity from the request 2. Look up the user by `clerkUserId` 3. If a Clerk `org_id` is present in the token, resolve the organization and verify membership 4. If no org is specified, fall back to the user's first organization membership ### requireAuth Wraps `getAuthContext` and throws `"Authentication required"` if the context is null. Used in mutations that require a logged-in user. ### requireOrgAdmin Checks that the authenticated user has the `admin` organization role. Used to guard admin-only operations like deleting an organization. ### isOrgAdmin Returns a boolean indicating whether the user's `userOrganizations` membership role is `admin`. Does not throw on non-admin access. ### API Key Authentication For API requests (agent chat, CLI sync), authentication uses API keys instead of Clerk JWTs. The `getAuthContextFromApiKey` function looks up the key by its SHA-256 hash and returns an `AuthContext` with `actorType: "system"`. ## Clerk Webhook Sync The platform syncs user and organization data from Clerk via the `/webhook/clerk` HTTP endpoint. Webhooks are verified using HMAC-SHA256 with the Svix signing protocol. ### Handled Events | Clerk Event | Handler | Effect | |-------------|---------|--------| | `user.created` | `getOrCreateFromClerkNoOrg` | Creates or updates the user record | | `user.updated` | `getOrCreateFromClerkNoOrg` | Updates the user's name if changed | | `organization.created` | `getOrCreateFromClerk` | Creates the organization record | | `organization.updated` | `getOrCreateFromClerk` | Updates the organization name | | `organization.deleted` | `markAsDeleted` | Schedules cascading deletion of all organization data | | `organizationMembership.created` | `syncMembership` | Creates membership, applies pending role assignments, creates role-bound entities | | `organizationMembership.updated` | `syncMembership` | Updates the membership role (admin/member) | | `organizationMembership.deleted` | `removeMembership` | Deletes membership and all user role assignments | ### Role Mapping Clerk organization roles are mapped to Struere membership roles: | Clerk Role | Struere Role | |------------|-------------| | `org:admin` | `admin` | | `org:owner` | `admin` | | All others | `member` | ## Deleting an Organization Organization deletion is admin-only and cascading. The `organizations.remove` mutation verifies the caller has `org:admin` or `org:owner` in Clerk, then schedules the `deleteAllOrgData` internal mutation which deletes all dependent data across all tables. ## Related - [Permissions](/platform/permissions) — Permission engine that enforces role-based access - [defineRole](/sdk/define-role) — SDK function for creating roles with policies and scope rules - [Environment Isolation](/platform/environment-isolation) — Roles and data are scoped per environment - [How do I set up RBAC?](/knowledge-base/how-to-set-up-rbac) — Step-by-step guide to configuring roles --- ## Gotchas > Silent failure footguns to know about before you ship. Source: https://docs.struere.dev/platform/gotchas.md A reference for behaviors that won't show up in type signatures or API docs but will silently break your agents in production. Skim before you ship. ## Silent Failure Gotchas ### 1. PolicyConfig has no priority field `PolicyConfig` accepts `resource`, `actions`, and `effect` — that's it. There is no `priority` field. Adding one does nothing because policy evaluation always uses deny-overrides-allow: any matching `deny` policy wins regardless of order. ```typescript { resource: "payment", actions: ["read"], effect: "deny" } ``` If you want a more permissive rule on the same resource, narrow the deny by `actions` or `resource` — don't try to outrank it. ### 2. Scope rule operators Scope rule operators are `eq`, `neq`, `in`, and `contains`. Using `ne` (common in MongoDB and other systems) silently fails — the rule loads, validates, and never matches. There's no error. ```typescript { entityType: "session", field: "data.teacherId", operator: "neq", value: "actor.userId" } ``` If your scope rule is unexpectedly returning everything (or nothing), check the operator spelling first. ### 3. Entity link/unlink params The entity link and unlink operations use `fromId` and `toId`. Passing `fromEntityId` and `toEntityId` (the more verbose names you might assume) silently fails — the call returns successfully but no link is created. ```typescript struere.entity.link({ fromId: "...", toId: "...", relationType: "owner" }) ``` ### 4. Model IDs use OpenRouter format Model IDs must be in `provider/model-name` format — write `openai/gpt-5-mini`, not `gpt-5-mini`. Bare model names are rejected at runtime, but configuration sync may accept them. Always include the provider prefix: ```typescript model: { model: "anthropic/claude-sonnet-4" } ``` ### 5. Default model When `model` is omitted from agent config, the platform uses `openai/gpt-5-mini` with `temperature: 0.7` and `maxTokens: 4096`. If you're seeing unexpected output style or token costs, check whether your agent has a model set at all. ### 6. Field masks use allowlist strategy Field masks are fail-safe: if you define any field mask for an entity type, only the fields explicitly allowed (not hidden, not redacted) are exposed. New fields you add to the data type later will be hidden by default until you update the mask. This protects against accidental leaks but means schema changes need a mask review. ### 7. Template variables that fail Template variables that fail to resolve render as `[TEMPLATE_ERROR: variableName not found]` in the compiled prompt. The agent then sees that literal string and gets confused. Always test prompts before shipping: ```bash bunx struere compile-prompt bunx struere compile-prompt --phone +1234567890 ``` ### 8. Custom tool fetch is unrestricted Custom tool handlers receive a `fetch` function with no domain allowlist or rate limit. Handlers can call any URL on any port. Treat custom tool code as production network egress — review it like you would a webhook receiver, and never paste handler code from untrusted sources. ### 9. entity.query default limit `entity.query` has a default `limit` of 100 and a max of 100. Agents that need to reason over more data should paginate explicitly or use a `templateOnly` tool that pre-aggregates data into the system prompt. Asking for `limit: 500` does not raise the cap. ### 10. agent.chat depth limit `agent.chat` enforces a max depth of 3 with cycle detection. A calling B calling A is blocked at the second hop. Design agent graphs to be shallow — orchestrator at the top, specialists at the leaves, no diamond shapes. ### 11. Fixture sync deletes entities When you sync a fixture file in the eval environment, the platform deletes all existing entities of the listed types and recreates them from YAML. This is a full reset every sync, not an incremental upsert. Don't keep production-shaped data in the eval environment expecting it to persist between fixture syncs. ### 12. whatsappOwnedTemplates is org-scoped Most resources are environment-scoped (development, production, eval). `whatsappOwnedTemplates` is the exception — templates you register are shared across all environments in the same org. If you delete a template in dev, it disappears in prod too.