Skip to content

Data Model

Precis

This document defines every entity in Thinklio, the relationships between them, and the rules governing data ownership, visibility, and lifecycle. It is the single source of truth for "what things exist in this system and how they relate."

Thinklio's data model is implemented in Convex and lives at convex/schema.ts. Every entity in this document is expressed using Convex defineTable validators so the prose representation matches the code representation line for line. The schema file is the canonical source of truth at commit level; this document is the canonical reference for what each entity means, how it relates to others, and how it is used.

The model spans several functional domains. Core platform entities (accounts, teams, users) provide the multi-tenancy and membership substrate. Agent entities (agents, assignments, tools, templates) define what agents are, what they can do, and where they operate. Execution entities (interactions, steps, jobs, subjobs, observers) track all agent work from a single message through to multi-step deferred workflows. Core data structures (tasks, contacts, items, notes, tags) give agents structured objects to create, query, and update on behalf of users. Knowledge facts store structured information extracted from interactions, while the media and library system handles file storage, processing, and document-grounded retrieval. Communications entities manage outbound notification dispatch across channels. Platform services, billing, and access-control entities round out the model.

Every entity follows a consistent scoping model: user, team, or account. Visibility is governed by scope, with account policies acting as the ceiling that no lower layer can loosen. The type system for tasks, items, and notes provides extensibility: platform-reserved system types give consistent behaviour out of the box, while accounts can create custom types for domain-specific workflows.

For storage implementation details (indexes, reactive queries, R2 integration, media processing pipeline) see 05 Persistence, Storage & Ingestion. For agent architecture and the execution contract see 03 Agent Architecture & Extensibility. For security, governance, and policy enforcement see 07 Security & Governance. For Convex platform specifics see 11 Convex Reference. The pre-Convex SQL-flavoured representation of this model is preserved under archive/legacy-sql-data-model.md.

Table of contents

  1. Purpose
  2. Naming and type conventions
  3. Scope and visibility model
  4. Entity relationship overview
  5. Core platform entities
  6. Agent entities
  7. Agent execution entities
  8. Core data structures
  9. Knowledge
  10. Learned workflows
  11. Events
  12. Communications and channels
  13. Planning and execution scoring
  14. Platform services
  15. Media, storage and libraries
  16. Access and security
  17. Billing and resource management
  18. Agent tool integration
  19. API endpoints
  20. Agent usage patterns
  21. Data lifecycle
  22. Open questions
  23. Revision history

1. Purpose

This document serves three audiences:

Developers use it as the canonical reference for every table, field, relationship, and constraint in the system. When implementing a feature that touches data, this document answers "what exists, what are the rules, and where does it connect."

Architects use it to understand the full entity landscape, identify where new features land, and ensure consistency across the model.

Integration partners use the core data structures section (section 8), agent tool integration (section 18), and API endpoints (section 19) to build against a stable, published interface.

The entity shapes below are Convex defineTable validators. They are illustrative; the authoritative schema is convex/schema.ts in the repo. Where the two disagree, the code wins and this document needs updating.

2. Naming and type conventions

Table names are singular throughout: account, agent, tool, task, note. Join tables use both singular names: account_user, team_member, agent_tool. Exception: user_profile instead of user (reserved word in SQL environments; retained for compatibility with any downstream tools reading the Fivetran CDC stream, and because "user" is otherwise ambiguous in Thinklio prose).

Field names are camelCase in the Convex schema (accountId, createdBy, lastAccessed). SQL-flavoured snake_case remains in this document's prose only when referring to the conceptual field; the actual validator uses camelCase. Where a snake_case name appears inside a validator block below, it reflects the exact field name in convex/schema.ts.

Primary keys are Convex document IDs (_id), assigned automatically on insert. _id and _creationTime are implicit on every document and not declared in defineTable. Foreign key fields use v.id("targetTable") which gives both type-safety and reference checking at write time.

Timestamps are numbers (unix milliseconds). Convex stamps _creationTime automatically; an explicit updatedAt: v.number() is included only where the application needs the last-modified time (not just creation).

Enums are expressed as string unions: v.union(v.literal("active"), v.literal("suspended"), v.literal("deleted")). This gives TypeScript exhaustiveness checking at the call site.

Free-form JSON uses v.any() where the shape is genuinely open, or v.object({...}) where the shape is known and should be enforced. Preference is always for v.object where we can specify it.

Embeddings use v.array(v.float64()) with a vectorIndex declaration on the table specifying dimensions, vectorField, and filterFields. Dimensions depend on the embedding model (1536 for text-embedding-3-small, 1024 for text-embedding-3-large-mini, etc.). The field on library_item and knowledge_fact is declared as v.array(v.float64()).

Monetary amounts are numbers representing the smallest currency unit (cents for USD) or a fixed-precision float. Convex has no native decimal type; application code works in cents and formats on display.

Soft-delete uses deletedAt: v.optional(v.number()). Queries filter by deletedAt === undefined by default.

Index naming follows a consistent scheme: by_<field> for single-field, by_<field1>_<field2> for compound. Index fields order matters and should match query access patterns.

2.1 SQL to Convex type mapping

For developers coming from the legacy SQL schema, this mapping shows how each SQL type translates:

SQL type Convex validator Notes
UUID (primary key) implicit _id Not declared; Convex generates it.
UUID (foreign key) v.id("table") Also enforces the reference at write time.
TEXT / VARCHAR v.string() No length cap distinction.
INTEGER / BIGINT v.number() JavaScript number; fine up to 2^53 - 1.
DECIMAL / NUMERIC v.number() Application works in smallest currency units (cents).
BOOLEAN v.boolean()
TIMESTAMPTZ v.number() Unix ms. Use _creationTime where "created at" is sufficient.
DATE v.string() ISO-8601 date string "YYYY-MM-DD".
JSONB v.object({...}) or v.any() Prefer object when shape is known.
ENUM v.union(v.literal(...), ...)
TEXT[] v.array(v.string())
VECTOR(N) v.array(v.float64()) + vectorIndex Vector index declared on the table.
nullable field v.optional(T)

3. Scope and visibility model

All scoped entities follow the platform's standard three-tier model:

Scope Visible to Typical use
user The owning user only Personal tasks, private contacts, personal notes
team All members of the team Shared task lists, team contacts, team items
account All account members Organisation-wide items, shared contact database, company notes

Exceptions and refinements:

  • Items are never user-scoped. They are organisational by nature; the minimum scope is team.
  • Contacts carry an additional visibility field (private, team, account) that can restrict access below the scope level. A private contact in an account scope is visible only to its creator.
  • Admins and owners can see all entities regardless of visibility, for administration and data management purposes.
  • Notes can be shared beyond their scope via note_share records without changing the note's scope.
  • Scope enforcement runs in Convex middleware (see 07 Security & Governance). Every query resolves the caller via Clerk, validates account or team membership, and returns only records the caller is permitted to see. There is no row-level security in Convex equivalent to Postgres RLS; the discipline is "every helper verifies scope."

Policy layering: Account policies set the ceiling. Team policies can tighten within account bounds. User preferences can tighten further. No layer can loosen a restriction set above it. This is consistent across the entire platform, not just the data model.

4. Entity relationship overview

┌──────────────┐
│   account    │
└──────┬───────┘
       │ has many
       ├──────────────┐
       │              │
┌──────▼───────┐ ┌────▼──────────┐
│     team     │ │  account_user │ (membership + role)
└──────┬───────┘ └───────────────┘
       │ has many
┌──────▼───────┐
│ team_member  │ (membership + role)
└──────────────┘

┌──────────────┐
│    agent     │ (first-class, independent)
└──────┬───────┘
       │ assigned via
┌──────▼────────────────┐
│ agent_assignment      │ (agent → user | team | account, with scope
│                       │  and per-assignment tool restrictions)
└───────────────────────┘

┌──────────────┐
│ user_profile │ (platform-global identity)
└──────────────┘

┌──────────────┐        ┌──────────────┐
│ interaction  │───────▶│     step     │ (1:many)
│              │        └──────────────┘
│              │
│              │───────▶ parentInteractionId (delegation chain)
└──────────────┘

┌──────────────┐        ┌──────────────┐
│     job      │───────▶│    subjob    │ (1:many)
│              │        └──────────────┘
│              │
│              │───────▶┌──────────────┐
│              │        │ job_observer │ (many observers per job)
└──────────────┘        └──────────────┘

┌──────────────────────────────────────┐
│      Core Data Structures            │
├──────────┐  ┌──────────┐  ┌────────┐│
│   task   │  │ contact  │  │  item  ││
└────┬─────┘  └────┬─────┘  └───┬────┘│
     │             │             │     │
     └─────────────┼─────────────┘     │
                   ▼                   │
                 note                  │
                   │                   │
                   └──────▶ tag ◀──────┘
                        entity_tag
                   (task/contact/item/note)
└──────────────────────────────────────┘

┌──────────────┐
│knowledge_fact│ (scoped: agent | account | team | user)
└──────────────┘

┌──────────────┐
│    tool      │ (type: internal | external | mcp | agent)
└──────┬───────┘
       │ assigned via
┌──────▼───────┐
│  agent_tool  │ (agent → tool, with permission level)
└──────────────┘

┌──────────────┐
│   workflow   │ (learned, first-class)
└──────────────┘

┌──────────────┐
│    event     │ (immutable, append-only)
└──────────────┘

Additional entity groups (abbreviated in the diagram for clarity): platform services (platform_service, llm_model, account_service_config, account_llm_preference), communications (channel_config, user_channel, user_comm, notification), planning (canonical_plan, execution_outcome, plan_score), media and storage (storage_bucket, account_storage_bucket, media, media_processor, media_processing_rule, media_processing_job, library, library_item, agent_library), access and security (invitation, api_key, oauth_token, webhook_subscription), and billing (credit_ledger, budget_limit, usage_record, quality_rating, platform_config).

5. Core platform entities

5.1 user_profile

A person on the platform. Users have a single global identity and can participate in multiple accounts and teams. Authentication is managed by Clerk; the user_profile record is created on first login via a webhook and subsequently kept in sync via additional webhooks on profile changes.

user_profile: defineTable({
  clerkUserId: v.string(),
  displayName: v.string(),
  email: v.string(),
  status: v.union(
    v.literal("active"),
    v.literal("suspended"),
    v.literal("deleted"),
  ),
  settings: v.any(),
  updatedAt: v.number(),
})
  .index("by_clerk", ["clerkUserId"])
  .index("by_email", ["email"])
  .index("by_status", ["status"]),

Rules:

  • A user exists independently of any account.
  • A user can create agents directly for personal use.
  • A user can belong to multiple accounts with different roles.
  • Deleting a user triggers data portability and retention procedures (section 21).
  • Credentials are never stored in this table; Clerk owns credential storage.

5.2 account

A billing and governance entity. Accounts own policies, budgets, and administrative control. Accounts map to Clerk organisations; the clerkOrgId is the authoritative identifier.

account: defineTable({
  clerkOrgId: v.string(),
  name: v.string(),
  slug: v.string(),
  plan: v.union(
    v.literal("free"),
    v.literal("team"),
    v.literal("account"),
    v.literal("enterprise"),
  ),
  settings: v.any(),
  policies: v.any(),
  budgetMonthly: v.number(),
  status: v.union(
    v.literal("active"),
    v.literal("suspended"),
    v.literal("archived"),
  ),
  storage: v.object({
    quotaBytes: v.number(),
    usedBytes: v.number(),
  }),
  updatedAt: v.number(),
})
  .index("by_clerk_org", ["clerkOrgId"])
  .index("by_slug", ["slug"])
  .index("by_status", ["status"]),

Rules:

  • An account is the anchor for billing.
  • Account policies override all lower-level settings.
  • Budget enforcement happens at the account level first, then team, then user.
  • Account policies include delegation governance: max_delegation_depth, delegation approval requirements.

5.3 account_user

Links users to accounts with roles.

account_user: defineTable({
  accountId: v.id("account"),
  userId: v.id("user_profile"),
  role: v.union(
    v.literal("owner"),
    v.literal("admin"),
    v.literal("editor"),
    v.literal("viewer"),
  ),
  joinedAt: v.number(),
})
  .index("by_account", ["accountId"])
  .index("by_user", ["userId"])
  .index("by_account_user", ["accountId", "userId"]),

Role descriptions:

  • owner: Full access including billing, account deletion, and ownership transfer.
  • admin: Manage teams, members, agents, and settings. No billing or account deletion.
  • editor: Create and edit agents, manage knowledge, use all agent features.
  • viewer: Read-only access. Can interact with assigned agents but cannot create or modify configuration.

Rules:

  • Every account has at least one owner.
  • Owners can manage admins and members; admins can manage editors and viewers.
  • A user's role determines what they can configure within the account.

5.4 team

A group of users within an account who share context and agents.

team: defineTable({
  accountId: v.optional(v.id("account")),
  name: v.string(),
  slug: v.string(),
  settings: v.any(),
  budgetMonthly: v.number(),
  status: v.union(v.literal("active"), v.literal("archived")),
  updatedAt: v.number(),
})
  .index("by_account", ["accountId"])
  .index("by_account_slug", ["accountId", "slug"]),

Rules:

  • Teams belong to accounts, or are standalone for personal use (null accountId).
  • Team knowledge is isolated: Team A's knowledge is invisible to Team B.
  • Team budgets must not exceed the parent account budget.

5.5 team_member

Links users to teams with roles.

team_member: defineTable({
  teamId: v.id("team"),
  userId: v.id("user_profile"),
  role: v.union(
    v.literal("admin"),
    v.literal("member"),
    v.literal("readonly"),
  ),
  joinedAt: v.number(),
})
  .index("by_team", ["teamId"])
  .index("by_user", ["userId"])
  .index("by_team_user", ["teamId", "userId"]),

6. Agent entities

6.1 agent

A first-class platform entity: the AI assistant. Agents exist independently and are assigned to contexts. For the full agent architecture, execution contract, and composition model see 03 Agent Architecture & Extensibility.

agent: defineTable({
  name: v.string(),
  slug: v.string(),
  description: v.string(),
  systemPrompt: v.string(),
  capabilityLevel: v.union(
    v.literal("tools_only"),
    v.literal("workflow"),
    v.literal("experimental"),
    v.literal("learning"),
  ),
  llmProvider: v.string(),
  llmModel: v.string(),
  settings: v.any(),
  status: v.union(
    v.literal("active"),
    v.literal("paused"),
    v.literal("archived"),
  ),
  createdBy: v.id("user_profile"),
  templateId: v.optional(v.id("agent_catalog")),
  updatedAt: v.number(),
})
  .index("by_slug", ["slug"])
  .index("by_created_by", ["createdBy"])
  .index("by_status", ["status"]),

Rules:

  • Agents are created by users (personally) or by account/team admins.
  • An agent can be assigned to multiple contexts simultaneously.
  • Agent knowledge (domain expertise, learned workflows) travels with the agent.
  • The capabilityLevel determines what the agent is allowed to do.
  • Pausing stops new interactions; archiving preserves the audit trail.
  • An agent can be registered as a tool (type agent) for other agents to invoke via delegation.

6.2 agent_assignment

Links agents to contexts (users, teams, or accounts) with scope configuration and per-assignment tool restrictions.

agent_assignment: defineTable({
  agentId: v.id("agent"),
  contextType: v.union(
    v.literal("user"),
    v.literal("team"),
    v.literal("account"),
  ),
  contextId: v.string(),              // string because it may point at user_profile | team | account
  accessLevel: v.union(
    v.literal("interact"),
    v.literal("configure"),
    v.literal("admin"),
  ),
  knowledgeScope: v.any(),
  budgetMonthly: v.number(),
  toolRestrictions: v.any(),
  status: v.union(v.literal("active"), v.literal("suspended")),
  updatedAt: v.number(),
})
  .index("by_agent", ["agentId"])
  .index("by_context", ["contextType", "contextId"])
  .index("by_agent_context", ["agentId", "contextType", "contextId"]),

Rules:

  • An agent can have multiple assignments (to a team and to individual users, for example).
  • Each assignment has its own knowledge scope and budget.
  • Knowledge isolation is per-assignment: Team A's knowledge is separate from Team B's, even for the same agent.
  • Removing an assignment does not delete the agent.
  • toolRestrictions can only narrow, never widen, the agent's configured tool permissions. An assignment cannot grant tool access that the agent does not already have.
  • Tool restrictions apply equally to regular tools and agent-as-tool delegations. A scheduler agent assigned to a team can be restricted to read-only calendar operations for that specific assignment.

Tool restrictions format:

{
    "scheduler_agent": {
        "allowed_actions": ["find_free_time", "check_conflicts"],
        "denied_actions": ["create_event"]
    },
    "search_web": {
        "max_calls_per_interaction": 3
    },
    "send_email": {
        "blocked": true
    }
}

Policy evaluation order for tool access:

  1. Agent configuration: does the agent have this tool assigned? (Maximum capability.)
  2. Assignment restrictions: does the assignment narrow the tool's permissions for this context?
  3. Account policies: do account-level policies impose further restrictions?

Each layer can only restrict, never expand.

6.3 tool

A capability available to agents. Tools can be platform-provided, external webhook/API integrations, MCP-discovered, or other agents (agent-as-tool delegation).

tool: defineTable({
  slug: v.string(),
  name: v.string(),
  description: v.string(),
  type: v.union(
    v.literal("internal"),
    v.literal("external"),
    v.literal("mcp"),
    v.literal("agent"),
  ),
  trustLevel: v.union(
    v.literal("read"),
    v.literal("low_risk_write"),
    v.literal("high_risk_write"),
  ),
  parameterSchema: v.any(),
  returnSchema: v.any(),
  config: v.any(),
  rateLimit: v.any(),
  defaultExecutionMode: v.union(
    v.literal("immediate"),
    v.literal("deferred"),
  ),
  version: v.string(),
  status: v.union(
    v.literal("active"),
    v.literal("deprecated"),
    v.literal("disabled"),
  ),
  registeredBy: v.optional(v.string()),   // may be user_profile id or account id
  updatedAt: v.number(),
})
  .index("by_slug", ["slug"])
  .index("by_type_status", ["type", "status"])
  .index("by_registered_by", ["registeredBy"]),

Rules for agent-type tools:

  • When type is "agent", the config field contains { "agent_id": "<id>" } identifying the delegate agent.
  • parameterSchema defines the invocation contract: what structured input the delegate accepts.
  • returnSchema defines what structured output the delegate produces.
  • trustLevel reflects the delegate agent's most sensitive configured capability.
  • Cycle detection is enforced at configuration time (when registering an agent-as-tool, the system checks for cycles in the delegation graph) and at runtime (each delegation carries a chain of agent IDs; invoking an agent already in the chain is denied).

Rules for dynamic registration (Integration API):

  • External systems can register tools through the Integration API with governance approval.
  • Dynamically registered tools go through the same policy evaluation as statically configured tools.
  • The registeredBy field tracks who registered the tool for audit purposes.
  • Health monitoring applies to external and dynamically registered tools; unhealthy tools are circuit-broken.

6.4 agent_tool

Links agents to tools with permission levels.

agent_tool: defineTable({
  agentId: v.id("agent"),
  toolId: v.id("tool"),
  permission: v.union(v.literal("read"), v.literal("readwrite")),
  configOverride: v.any(),
})
  .index("by_agent", ["agentId"])
  .index("by_tool", ["toolId"])
  .index("by_agent_tool", ["agentId", "toolId"]),

6.5 agent_catalog

Pre-configured templates for creating agents, including delegation relationships for composed agents. In 03 Agent Architecture & Extensibility this entity is referred to as the platform catalogue; in the legacy model it was agent_template.

agent_catalog: defineTable({
  name: v.string(),
  description: v.string(),
  systemPrompt: v.string(),
  capabilityLevel: v.union(
    v.literal("tools_only"),
    v.literal("workflow"),
    v.literal("experimental"),
    v.literal("learning"),
  ),
  toolAssignments: v.any(),
  delegationConfig: v.any(),
  knowledgeSeeds: v.any(),
  libraryAssignments: v.any(),
  scope: v.union(v.literal("platform"), v.literal("account")),
  scopeId: v.optional(v.id("account")),
  createdBy: v.id("user_profile"),
})
  .index("by_scope", ["scope", "scopeId"])
  .index("by_created_by", ["createdBy"]),

Rules:

  • Templates for composed agents (Personal Assistant with Scheduler and Research, for example) pre-configure delegation relationships and tool assignments.
  • Cycle detection runs when a template is created or modified that includes agent-as-tool delegations.
  • Platform templates (scope = "platform") are available to all accounts. Account templates are scoped to a single account.
  • Platform templates may reference platform-scoped libraries; account templates may reference account-scoped libraries within their scope.

7. Agent execution entities

7.1 interaction

A unit of work initiated by a user message. Contains one or more steps. Supports delegation chains through parent interaction linking. For the full execution contract and harness design see 03 Agent Architecture & Extensibility.

interaction: defineTable({
  agentId: v.id("agent"),
  userId: v.id("user_profile"),
  teamId: v.optional(v.id("team")),
  accountId: v.optional(v.id("account")),
  sessionId: v.string(),
  channel: v.string(),
  state: v.union(
    v.literal("pending"),
    v.literal("running"),
    v.literal("success"),
    v.literal("failed"),
  ),
  totalCost: v.number(),
  parentInteractionId: v.optional(v.id("interaction")),
  delegationDepth: v.number(),
  startedAt: v.number(),
  completedAt: v.optional(v.number()),
  metadata: v.any(),
})
  .index("by_agent", ["agentId"])
  .index("by_user", ["userId"])
  .index("by_account_started", ["accountId", "startedAt"])
  .index("by_session", ["sessionId"])
  .index("by_parent", ["parentInteractionId"])
  .index("by_state", ["state"]),

Rules:

  • State is derived from constituent steps (see harness design in doc 03).
  • Cost is the sum of all step costs.
  • The accountId and teamId provide cost attribution context.
  • When parentInteractionId is set, this interaction's cost becomes the act step cost in the parent interaction. Cost rolls up through the delegation chain to the originating user, team, and account.
  • delegationDepth is checked against the account policy's max_delegation_depth setting (default: 3). The policy engine denies delegations that would exceed this limit.

7.2 step

An individual unit of execution within an interaction. Steps carry an execution mode that determines how the harness orchestrates post-step flow.

step: defineTable({
  interactionId: v.id("interaction"),
  stepType: v.union(
    v.literal("context"),
    v.literal("think"),
    v.literal("act"),
    v.literal("observe"),
    v.literal("respond"),
    v.literal("extract"),
  ),
  stepOrder: v.number(),
  state: v.union(
    v.literal("created"),
    v.literal("running"),
    v.literal("success"),
    v.literal("failed"),
  ),
  executionMode: v.optional(v.union(
    v.literal("immediate"),
    v.literal("deferred"),
    v.literal("interactive"),
  )),
  inputData: v.any(),
  outputData: v.any(),
  errorData: v.any(),
  cost: v.number(),
  costDetail: v.any(),
  jobId: v.optional(v.id("job")),
  startedAt: v.number(),
  completedAt: v.optional(v.number()),
})
  .index("by_interaction", ["interactionId"])
  .index("by_interaction_order", ["interactionId", "stepOrder"])
  .index("by_state", ["state"])
  .index("by_job", ["jobId"]),

Rules:

  • Steps follow the state machine: created then running then success or failed.
  • Failed steps include errorData with reason: timeout, error, governance, budget, cancellation, delegation_depth_exceeded, delegation_cycle_detected.
  • Steps are persisted before execution begins (created state). Results are persisted on completion.
  • Resumption: find steps in created or running state and re-execute. The Convex Workflow component (see 11 Convex Reference) handles durable step state for the harness.
  • For act steps, executionMode determines harness behaviour:
  • immediate: step executes synchronously, harness waits for result.
  • deferred: step dispatches work and creates a Job, succeeds on dispatch (not on work completion).
  • interactive: step result feeds back into a new think step for further reasoning.
  • A deferred act step's jobId links to the job entity for tracking.

7.3 job

A unit of work that outlives a single interaction. Created when a deferred act step dispatches work to an external execution engine or a delegate agent with unpredictable completion time. For the full job system design see 03 Agent Architecture & Extensibility section 8.

job: defineTable({
  type: v.string(),
  createdByAgent: v.id("agent"),
  createdByInteraction: v.id("interaction"),
  sessionId: v.string(),
  state: v.union(
    v.literal("pending"),
    v.literal("dispatched"),
    v.literal("in_progress"),
    v.literal("resolved"),
    v.literal("failed"),
    v.literal("cancelled"),
    v.literal("timed_out"),
  ),
  hasUsefulOutput: v.boolean(),
  dispatchTarget: v.any(),
  dispatchPayload: v.any(),
  contextBundle: v.any(),
  usefulnessRule: v.string(),
  timeoutAt: v.number(),
  updatedAt: v.number(),
})
  .index("by_agent", ["createdByAgent"])
  .index("by_interaction", ["createdByInteraction"])
  .index("by_state", ["state"])
  .index("by_timeout", ["timeoutAt"])
  .index("by_session", ["sessionId"]),

State machine:

pending -> dispatched -> in_progress -> resolved
                                     -> failed
                                     -> cancelled
                                     -> timed_out

Rules:

  • Jobs inherit the governance context (budget, policies, audit trail) of the creating interaction. Deferred execution is not an escape hatch from governance.
  • All job state lives in the Convex database. Real-time subscriptions replace the polling pattern the legacy architecture used. The admin UI and the originating agent both subscribe to job state changes via reactive queries (see 05 Persistence, Storage & Ingestion section 5 for reactive patterns).
  • The contextBundle carries forward state that follow-up interactions need. When a PA checks a calendar (immediate step) then dispatches research (deferred step), the calendar result is stored in the job's context bundle so the follow-up interaction has access to both the research output and the earlier calendar result.
  • resolved means all subjobs have reached a terminal state and at least one succeeded. The observing agent evaluates whether the result set constitutes full success, acceptable partial success, or effective failure.
  • failed means all subjobs terminated and none succeeded.
  • Retention follows the same policies as interactions and events (configurable per account, default 90 days active, then archived).

7.4 subjob

A discrete unit of work within a job. A simple deferred job has a single subjob. A complex job (generate five research articles, for example) has multiple subjobs, enabling granular progress tracking and partial output notification.

subjob: defineTable({
  jobId: v.id("job"),
  label: v.string(),
  order: v.number(),
  state: v.union(
    v.literal("pending"),
    v.literal("running"),
    v.literal("completed"),
    v.literal("failed"),
  ),
  resultData: v.any(),
  errorData: v.any(),
  startedAt: v.optional(v.number()),
  completedAt: v.optional(v.number()),
})
  .index("by_job", ["jobId"])
  .index("by_job_order", ["jobId", "order"])
  .index("by_state", ["state"]),

Rules:

  • When a subjob completes, the parent job evaluates its usefulness rule.
  • The default rule (any_completed_subjob): at least one subjob completed with non-null resultData while other subjobs are still in progress sets hasUsefulOutput = true on the job and notifies qualifying observers.
  • When all subjobs reach terminal states: the job transitions to resolved (if any succeeded) or failed (if none succeeded).
  • Subjobs within a timed-out or cancelled job are transitioned to failed with appropriate error reasons.

7.5 job_observer

An entity registered to receive notifications about a job's state changes. Decouples job creation from job consumption.

job_observer: defineTable({
  jobId: v.id("job"),
  observerType: v.union(v.literal("agent"), v.literal("system")),
  observerId: v.string(),                  // agent id or system process identifier
  assignmentId: v.optional(v.id("agent_assignment")),
  notifyOn: v.union(
    v.literal("completion_only"),
    v.literal("failure_only"),
    v.literal("partial_and_completion"),
    v.literal("all_changes"),
  ),
  callbackMetadata: v.any(),
  registeredAt: v.number(),
})
  .index("by_job", ["jobId"])
  .index("by_observer", ["observerType", "observerId"]),

Notification filtering:

notifyOn Receives
completion_only Terminal states only: resolved, failed, cancelled, timed_out
failure_only Failed, cancelled, timed_out only
partial_and_completion Terminal states plus partial output availability
all_changes Every state transition

Rules:

  • The creating agent is automatically registered as an observer when a job is created.
  • Additional observers can be added by any agent with visibility into the job (governed by assignment context and account policies).
  • Terminal state notifications bypass observer preferences: all observers are notified when a job reaches a terminal state.
  • A periodic cleanup process removes observer registrations pointing to deleted agents.
  • Observer types include agent (internal) and system (for monitoring processes and, in future, external webhook subscribers via the Platform API).

8. Core data structures

These four universal data structures give agents structured objects to work with. They are not app features; they are the substrate that agents create, query, and update on behalf of users. The UI surfaces them, but the primary interface is the agent tool system (section 18).

Design principles:

  1. Agent-first. These structures exist so agents can operate on structured data. The UI is a secondary read/write interface.
  2. Scope-aware. Every entity follows the standard scoping model (section 3). Visibility is governed by scope and the role model.
  3. Lightweight. These are not replacements for Jira, Salesforce, or Notion. They provide just enough structure for agents to work with. Accounts that need deeper functionality use integrations (Todoist, HubSpot) with optional two-way sync.
  4. Extensible via metadata. Every entity carries a metadata field for domain-specific data without schema changes. A support agent stores SLA data in item metadata. A CRM agent stores deal stage in contact metadata.
  5. Published API. The tool and HTTP interfaces are public. Custom agents and external integrations use these structures exactly as built-in agents do.
  6. Typed and customisable. Tasks, items, and notes have a type system: platform-reserved system types provide consistent behaviour, while accounts can create custom types for domain-specific workflows.

8.1 task_list

A named group of tasks. Lists provide simple organisation without the overhead of projects or boards.

task_list: defineTable({
  accountId: v.id("account"),
  scope: v.union(v.literal("user"), v.literal("team"), v.literal("account")),
  scopeId: v.string(),
  name: v.string(),
  description: v.optional(v.string()),
  position: v.number(),
  isArchived: v.boolean(),
  createdBy: v.id("user_profile"),
  updatedAt: v.number(),
})
  .index("by_account", ["accountId"])
  .index("by_account_scope", ["accountId", "scope", "scopeId"]),

Visibility: User-scoped lists are private. Team-scoped lists are visible to team members. Account-scoped lists are visible to all account members.

8.2 task

An individual unit of work. Can belong to a list or be unassigned (inbox).

task: defineTable({
  accountId: v.id("account"),
  listId: v.optional(v.id("task_list")),
  taskTypeId: v.optional(v.id("task_type")),
  title: v.string(),
  description: v.optional(v.string()),
  status: v.union(
    v.literal("todo"),
    v.literal("in_progress"),
    v.literal("done"),
    v.literal("cancelled"),
  ),
  priority: v.union(
    v.literal("urgent"),
    v.literal("high"),
    v.literal("normal"),
    v.literal("low"),
  ),
  dueDate: v.optional(v.string()),         // ISO date YYYY-MM-DD
  dueTime: v.optional(v.string()),         // HH:MM
  assignedTo: v.optional(v.id("user_profile")),
  createdBy: v.optional(v.id("user_profile")),
  createdByAgent: v.optional(v.id("agent")),
  completedAt: v.optional(v.number()),
  position: v.number(),
  scope: v.union(v.literal("user"), v.literal("team"), v.literal("account")),
  scopeId: v.string(),
  sourceItemId: v.optional(v.id("item")),
  recurrence: v.optional(v.string()),      // RRULE string
  recurrenceParentId: v.optional(v.id("task")),
  nextOccurrence: v.optional(v.number()),
  metadata: v.any(),
  updatedAt: v.number(),
})
  .index("by_account_assigned_status_due", ["accountId", "assignedTo", "status", "dueDate"])
  .index("by_account_list_position", ["accountId", "listId", "position"])
  .index("by_account_scope_status", ["accountId", "scope", "scopeId", "status"])
  .index("by_account_recurrence_parent", ["accountId", "recurrenceParentId"])
  .index("by_next_occurrence", ["nextOccurrence"]),

Relationship to items: When an item (ticket/request) generates work for a user, a task is created with sourceItemId linking back. The item tracks the request lifecycle; the task tracks the user's work. Completing the task may or may not resolve the item.

Recurring tasks: A task with a recurrence RRULE (RFC 5545 format, e.g. FREQ=WEEKLY;BYDAY=MO) is a recurring task definition. The platform generates individual task instances as children (linked via recurrenceParentId) when each occurrence is due. The parent task's nextOccurrence is updated after each generation. Completing or cancelling a child task does not affect the parent or future occurrences. Modifying or deleting the parent stops future generation.

8.3 task_type

Categorises tasks into system-reserved and account-custom types.

task_type: defineTable({
  accountId: v.optional(v.id("account")),       // null for system types
  slug: v.string(),
  name: v.string(),
  description: v.optional(v.string()),
  isSystem: v.boolean(),
  icon: v.optional(v.string()),
  defaultStatus: v.string(),
  metadata: v.any(),
})
  .index("by_account_slug", ["accountId", "slug"])
  .index("by_is_system", ["isSystem"]),

System-reserved vs account-custom pattern: System types (isSystem = true, accountId = undefined) are seeded by the platform and available to all accounts. They cannot be modified or deleted. Account-custom types (isSystem = false, accountId set) are created by account admins for domain-specific workflows. System types include: standard, reminder, follow_up, onboarding, review, recurring.

Unique constraint: (accountId, slug). No duplicate slugs per account. System types use (undefined, slug).

8.4 contact

A person or organisation that the account interacts with.

contact: defineTable({
  accountId: v.id("account"),
  type: v.union(v.literal("person"), v.literal("organisation")),
  name: v.string(),
  email: v.optional(v.string()),
  phone: v.optional(v.string()),
  title: v.optional(v.string()),
  website: v.optional(v.string()),
  organisationId: v.optional(v.id("contact")),
  visibility: v.union(v.literal("private"), v.literal("team"), v.literal("account")),
  scope: v.union(v.literal("user"), v.literal("team"), v.literal("account")),
  scopeId: v.string(),
  source: v.union(
    v.literal("manual"),
    v.literal("agent"),
    v.literal("import"),
    v.literal("integration"),
  ),
  sourceRef: v.optional(v.string()),
  metadata: v.any(),
  createdBy: v.optional(v.id("user_profile")),
  createdByAgent: v.optional(v.id("agent")),
  updatedAt: v.number(),
})
  .index("by_account", ["accountId"])
  .index("by_account_type", ["accountId", "type"])
  .index("by_account_organisation", ["accountId", "organisationId"])
  .index("by_account_source_ref", ["accountId", "source", "sourceRef"])
  .index("by_email", ["email"]),

Visibility model:

  • private: only the creating user can see this contact. Personal contacts (friends, family, personal network).
  • team: visible to all members of the contact's team scope. Team-level business contacts.
  • account: visible to all account members. Shared business contacts, customers, partners.

Admins and owners can see all contacts regardless of visibility (for data management purposes).

Organisation linking: A person contact can reference an organisation contact via organisationId. This enables "show me everyone at Acme Corp" queries without a separate join table.

Integration sync: When an account connects HubSpot or another CRM, contacts can be synced bidirectionally. source = "integration" and sourceRef tracks the external ID to prevent duplicates and enable sync.

8.5 contact_interaction

A lightweight log of interactions with a contact. Not a full activity stream, just enough for agents to answer "when did we last talk to this person?"

contact_interaction: defineTable({
  contactId: v.id("contact"),
  type: v.union(
    v.literal("call"),
    v.literal("email"),
    v.literal("meeting"),
    v.literal("note"),
    v.literal("message"),
    v.literal("other"),
  ),
  summary: v.string(),
  detail: v.optional(v.string()),
  occurredAt: v.number(),
  loggedBy: v.optional(v.id("user_profile")),
  loggedByAgent: v.optional(v.id("agent")),
  metadata: v.any(),
})
  .index("by_contact", ["contactId"])
  .index("by_contact_occurred", ["contactId", "occurredAt"]),

8.6 item

An externally-initiated thing that needs processing. Items differ from tasks: tasks are "I need to do X", items are "X arrived and needs handling."

item: defineTable({
  accountId: v.id("account"),
  itemTypeId: v.optional(v.id("item_type")),
  type: v.union(
    v.literal("support_ticket"),
    v.literal("request"),
    v.literal("bug"),
    v.literal("inquiry"),
    v.literal("approval"),
    v.literal("other"),
  ),
  title: v.string(),
  description: v.string(),
  status: v.union(
    v.literal("open"),
    v.literal("in_progress"),
    v.literal("waiting"),
    v.literal("resolved"),
    v.literal("closed"),
  ),
  priority: v.union(
    v.literal("urgent"),
    v.literal("high"),
    v.literal("normal"),
    v.literal("low"),
  ),
  source: v.union(
    v.literal("email"),
    v.literal("web"),
    v.literal("api"),
    v.literal("agent"),
    v.literal("manual"),
    v.literal("channel"),
  ),
  sourceRef: v.optional(v.string()),
  assignedToUser: v.optional(v.id("user_profile")),
  assignedToTeam: v.optional(v.id("team")),
  assignedToAgent: v.optional(v.id("agent")),
  contactId: v.optional(v.id("contact")),
  resolvedAt: v.optional(v.number()),
  closedAt: v.optional(v.number()),
  scope: v.union(v.literal("team"), v.literal("account")),   // items are never user-scoped
  scopeId: v.string(),
  metadata: v.any(),
  createdBy: v.optional(v.id("user_profile")),
  createdByAgent: v.optional(v.id("agent")),
  updatedAt: v.number(),
})
  .index("by_account_status_priority", ["accountId", "status", "priority"])
  .index("by_account_assigned_user", ["accountId", "assignedToUser"])
  .index("by_account_contact", ["accountId", "contactId"])
  .index("by_account_type_status", ["accountId", "type", "status"]),

Item to task relationship: When an item is assigned to a user, the assigning agent (or admin) may create a task linked via task.sourceItemId. The item tracks the external request lifecycle. The task tracks the internal work. An item may generate zero, one, or many tasks.

Item workflow:

open -> in_progress -> resolved -> closed
         |                ^
       waiting -----------+

waiting = blocked on external input (waiting for customer reply, for example). resolved = work complete, pending confirmation. closed = confirmed done (or auto-closed after timeout).

8.7 item_type

Categorises items into system-reserved and account-custom types. Same pattern as task_type.

item_type: defineTable({
  accountId: v.optional(v.id("account")),
  slug: v.string(),
  name: v.string(),
  description: v.optional(v.string()),
  isSystem: v.boolean(),
  icon: v.optional(v.string()),
  defaultStatus: v.string(),
  defaultPriority: v.string(),
  allowedStatuses: v.optional(v.array(v.string())),
  metadata: v.any(),
})
  .index("by_account_slug", ["accountId", "slug"])
  .index("by_is_system", ["isSystem"]),

System types include: support_ticket, request, bug, inquiry, approval, other. Accounts can create custom types for domain-specific workflows (e.g. rfp, compliance_review, warranty_claim).

8.8 note

Freeform captured information. Meeting summaries, research, observations, ideas. Stored in markdown format.

note: defineTable({
  accountId: v.id("account"),
  noteTypeId: v.optional(v.id("note_type")),
  title: v.optional(v.string()),
  content: v.string(),                     // markdown
  scope: v.union(v.literal("user"), v.literal("team"), v.literal("account")),
  scopeId: v.string(),
  linkedType: v.optional(v.union(
    v.literal("task"),
    v.literal("contact"),
    v.literal("item"),
    v.literal("agent"),
  )),
  linkedId: v.optional(v.string()),
  createdBy: v.optional(v.id("user_profile")),
  createdByAgent: v.optional(v.id("agent")),
  metadata: v.any(),
  updatedAt: v.number(),
  editedAt: v.optional(v.number()),
})
  .index("by_account", ["accountId"])
  .index("by_account_scope", ["accountId", "scope", "scopeId"])
  .index("by_linked", ["linkedType", "linkedId"])
  .index("by_created_by", ["createdBy"]),

Linking: A note can be linked to one primary entity. "Notes from call with John" uses linkedType = "contact". "Research for task X" uses linkedType = "task". Notes without links are standalone (ideas, observations).

Knowledge extraction: Notes are candidates for automatic knowledge fact extraction. When a note is created, the platform can optionally run the fact_extract processor to derive structured knowledge facts from the freeform content.

Storage format: Note content is stored as markdown. The API accepts and returns markdown. Client rendering is the responsibility of the UI layer.

Edited timestamp: editedAt is set when content is modified after creation, distinct from updatedAt which changes on any field update. Enables "edited" indicators in the UI.

8.9 note_share

Granular sharing of individual notes beyond their scope visibility.

note_share: defineTable({
  noteId: v.id("note"),
  sharedWithUser: v.optional(v.id("user_profile")),
  sharedWithTeam: v.optional(v.id("team")),
  permission: v.union(v.literal("view"), v.literal("edit")),
  sharedBy: v.id("user_profile"),
})
  .index("by_note", ["noteId"])
  .index("by_user", ["sharedWithUser"])
  .index("by_team", ["sharedWithTeam"]),

Purpose: Allows a user-scoped note to be shared with specific users or teams without changing its scope. A user can share their private meeting notes with a colleague without making them team-visible. The note's scope remains user but the note_share records grant additional access.

8.10 note_mention

Tracks @mentions within notes for cross-entity linking and notification.

note_mention: defineTable({
  noteId: v.id("note"),
  mentionedType: v.union(
    v.literal("user"),
    v.literal("contact"),
    v.literal("task"),
    v.literal("item"),
    v.literal("agent"),
  ),
  mentionedId: v.string(),
  position: v.number(),                    // character offset in note content
})
  .index("by_note", ["noteId"])
  .index("by_mentioned", ["mentionedType", "mentionedId"]),

Purpose: When a note contains @John or @Task:Fix login bug, the platform extracts mentions and stores them. This enables notifications to mentioned users, "notes mentioning me" queries, cross-entity discovery ("find all notes that mention this contact"), and UI autocomplete for @mentions.

8.11 note_type

Categorises notes into system-reserved and account-custom types. Same pattern as task_type and item_type.

note_type: defineTable({
  accountId: v.optional(v.id("account")),
  slug: v.string(),
  name: v.string(),
  description: v.optional(v.string()),
  isSystem: v.boolean(),
  icon: v.optional(v.string()),
  metadata: v.any(),
})
  .index("by_account_slug", ["accountId", "slug"])
  .index("by_is_system", ["isSystem"]),

System types include: general, meeting, research, decision, standup, retrospective, idea. Accounts can create custom types for domain-specific note categories.

8.12 tag

Cross-cutting categorisation for any entity.

tag: defineTable({
  accountId: v.id("account"),
  name: v.string(),
  colour: v.string(),                      // hex colour
})
  .index("by_account_name", ["accountId", "name"]),

Unique constraint: (accountId, name). No duplicate tag names per account.

8.13 entity_tag

Polymorphic join table linking tags to any entity.

entity_tag: defineTable({
  tagId: v.id("tag"),
  entityType: v.union(
    v.literal("task"),
    v.literal("contact"),
    v.literal("item"),
    v.literal("note"),
  ),
  entityId: v.string(),
})
  .index("by_tag", ["tagId"])
  .index("by_entity", ["entityType", "entityId"])
  .index("by_tag_entity", ["tagId", "entityType", "entityId"]),

Unique constraint: (tagId, entityType, entityId). No duplicate tag assignments.

8.14 Entity attachments

Media files (images, documents, etc.) can be attached to any core data entity using the media table's polymorphic linking fields (linkedType and linkedId). This uses the existing media entity (section 15) rather than a separate attachment table. When a user attaches a file to a task or note, a media record is created with linkedType and linkedId pointing to the entity. Querying attachments is a simple filter on media by those two fields.

9. Knowledge

9.1 knowledge_fact

A structured piece of knowledge with scope and ownership. For the four-layer knowledge architecture (agent, account, team, user) and runtime resolution see 03 Agent Architecture & Extensibility section 11.

knowledge_fact: defineTable({
  scope: v.union(
    v.literal("agent"),
    v.literal("account"),
    v.literal("team"),
    v.literal("user"),
  ),
  scopeId: v.string(),
  agentId: v.id("agent"),
  subject: v.string(),
  predicate: v.string(),
  value: v.string(),
  category: v.string(),
  confidence: v.number(),
  sourceInteractionId: v.optional(v.id("interaction")),
  embedding: v.array(v.float64()),
  accessCount: v.number(),
  lastAccessed: v.number(),
  updatedAt: v.number(),
})
  .index("by_scope", ["scope", "scopeId"])
  .index("by_agent", ["agentId"])
  .index("by_subject", ["subject"])
  .vectorIndex("by_embedding", {
    vectorField: "embedding",
    dimensions: 1536,
    filterFields: ["agentId", "scope", "scopeId"],
  }),

Rules:

  • scope and scopeId together determine visibility:
  • agent scope: visible whenever this agent is used.
  • account scope: visible to all account members using this agent.
  • team scope: visible to team members only.
  • user scope: visible only to that specific user.
  • Precedence for conflicts: account > agent > team > user.
  • User facts are portable: they follow the user across accounts.
  • Team facts stay with the team when a user leaves.
  • When an agent delegates to another agent, knowledge extracted during the delegate's interaction is scoped to the delegate agent's assignment context. The invoking agent does not automatically receive knowledge extracted by the delegate. Cross-agent knowledge sharing happens through normal interaction flow.

10. Learned workflows

10.1 workflow

A codified problem-solving pattern learned by an agent. Distinct from the Convex Workflow component (which provides durable execution infrastructure); this entity captures reusable reasoning patterns.

workflow: defineTable({
  agentId: v.id("agent"),
  name: v.string(),
  description: v.string(),
  triggerPattern: v.string(),
  steps: v.any(),
  scope: v.union(v.literal("agent"), v.literal("team"), v.literal("account")),
  scopeId: v.string(),
  successCount: v.number(),
  version: v.number(),
  status: v.union(
    v.literal("draft"),
    v.literal("active"),
    v.literal("approved"),
    v.literal("deprecated"),
  ),
  updatedAt: v.number(),
})
  .index("by_agent", ["agentId"])
  .index("by_scope", ["scope", "scopeId"])
  .index("by_status", ["status"]),

Rules:

  • Workflows start in agent scope (private to the agent).
  • Admins can promote workflows to team or account scope.
  • Promotion creates a copy; the original remains with the agent.
  • Workflows require approval before becoming active at team or account level.

11. Events

11.1 event

The append-only event store. For the event system design and harness integration see 06 Events, Channels & Messaging.

event: defineTable({
  accountId: v.string(),
  kind: v.string(),                        // "message.received", "step.completed", ...
  actor: v.object({
    kind: v.union(v.literal("user"), v.literal("agent"), v.literal("system")),
    id: v.string(),
  }),
  target: v.optional(v.object({
    kind: v.string(),
    id: v.string(),
  })),
  payload: v.any(),
  causationId: v.optional(v.id("event")),
  correlationId: v.optional(v.string()),
  at: v.number(),
})
  .index("by_account_at", ["accountId", "at"])
  .index("by_kind_at", ["accountId", "kind", "at"])
  .index("by_correlation", ["correlationId"])
  .index("by_target", ["target.kind", "target.id"]),

Rules:

  • Events are immutable: never updated or deleted within the retention window.
  • The event table is the distribution substrate for reactive queries, replacing the legacy Redis Streams bus. Any consumer that needs a live feed of events subscribes to a query that reads the table.
  • Subject to data retention policies (archival after configurable period; default 90 days active, then archived to R2).
  • Job-related events (job.created, job.dispatched, job.state_changed, job.resolved, job.failed, job.cancelled, job.timed_out) follow the same immutability and retention rules.

12. Communications and channels

12.1 channel_config

Channel type configurations. For the full channel architecture see 06 Events, Channels & Messaging.

channel_config: defineTable({
  accountId: v.id("account"),
  channelType: v.union(
    v.literal("telegram"),
    v.literal("email"),
    v.literal("web"),
    v.literal("api"),
  ),
  config: v.any(),                          // credential refs, not raw credentials
  status: v.union(v.literal("active"), v.literal("disabled")),
  updatedAt: v.number(),
})
  .index("by_account", ["accountId"])
  .index("by_account_type", ["accountId", "channelType"]),

Rules:

  • Credentials in config are stored in the secrets vault (section 16.3 for the pattern; 07 Security & Governance for the full model). Only references are stored here.
  • Each account can have multiple channels of the same type (multiple email accounts, for example).

12.2 user_channel

User-to-channel identity links. Connects a user's external identity (Telegram ID, email address) to their Thinklio account so messages from any linked channel are attributed to the correct user.

user_channel: defineTable({
  userId: v.id("user_profile"),
  channelType: v.string(),
  externalId: v.string(),
  displayName: v.string(),
  status: v.union(
    v.literal("pending_verification"),
    v.literal("active"),
    v.literal("disabled"),
  ),
  verifiedAt: v.optional(v.number()),
  lastActiveAt: v.number(),
})
  .index("by_user", ["userId"])
  .index("by_external", ["channelType", "externalId"]),

12.3 user_comm

Outbound notification dispatch queue. Handles messages that the platform needs to deliver to users outside of agent chats.

user_comm: defineTable({
  accountId: v.id("account"),
  userId: v.id("user_profile"),
  commType: v.union(
    v.literal("task_reminder"),
    v.literal("task_due"),
    v.literal("item_assigned"),
    v.literal("mention"),
    v.literal("share"),
    v.literal("digest"),
    v.literal("system"),
  ),
  channel: v.union(
    v.literal("email"),
    v.literal("push"),
    v.literal("in_app"),
    v.literal("telegram"),
  ),
  subject: v.string(),
  body: v.string(),
  entityType: v.optional(v.string()),
  entityId: v.optional(v.string()),
  status: v.union(
    v.literal("pending"),
    v.literal("sent"),
    v.literal("delivered"),
    v.literal("failed"),
    v.literal("cancelled"),
  ),
  scheduledFor: v.optional(v.number()),
  sentAt: v.optional(v.number()),
  error: v.optional(v.string()),
  metadata: v.any(),
})
  .index("by_user", ["userId"])
  .index("by_account_status", ["accountId", "status"])
  .index("by_status_scheduled", ["status", "scheduledFor"]),

Dispatch pattern: A Convex scheduled function reads user_comm for pending messages due now, resolves the user's preferred delivery channel, and enqueues a Convex action to dispatch via the appropriate transport (email via Postmark, push notification, in-app notification record, or Telegram message). Failed deliveries are retried with exponential backoff. A reminders module generates task reminder and due-date notifications by scanning tasks and creating user_comm records.

Relationship to notifications: The notification table stores in-app notifications (visible in the UI notification centre). user_comm is the dispatch queue for all outbound channels. When channel = "in_app", the worker creates a notification record. For other channels, it dispatches externally.

12.4 notification

In-app notification records, visible in the UI notification centre.

notification: defineTable({
  userId: v.id("user_profile"),
  accountId: v.id("account"),
  title: v.string(),
  body: v.string(),
  notificationType: v.string(),
  referenceType: v.optional(v.string()),
  referenceId: v.optional(v.string()),
  readAt: v.optional(v.number()),
})
  .index("by_user", ["userId"])
  .index("by_user_unread", ["userId", "readAt"])
  .index("by_reference", ["referenceType", "referenceId"]),

13. Planning and execution scoring

13.1 canonical_plan

Reusable plan definitions for agent reasoning. Plans codify problem-solving approaches that agents can evaluate and select based on effectiveness history.

canonical_plan: defineTable({
  name: v.string(),
  description: v.string(),
  planType: v.string(),
  steps: v.any(),
  scope: v.union(
    v.literal("platform"),
    v.literal("account"),
    v.literal("agent"),
  ),
  scopeId: v.optional(v.string()),
  planHash: v.string(),
})
  .index("by_hash", ["planHash"])
  .index("by_scope", ["scope", "scopeId"])
  .index("by_plan_type", ["planType"]),

13.2 execution_outcome

Recorded outcomes of plan executions, providing the raw data for effectiveness scoring.

execution_outcome: defineTable({
  canonicalPlanId: v.id("canonical_plan"),
  agentId: v.id("agent"),
  interactionId: v.id("interaction"),
  success: v.boolean(),
  cost: v.number(),
  durationMs: v.number(),
  contextHash: v.string(),
})
  .index("by_plan", ["canonicalPlanId"])
  .index("by_agent", ["agentId"])
  .index("by_interaction", ["interactionId"])
  .index("by_plan_context", ["canonicalPlanId", "contextHash"]),

13.3 plan_score

Bayesian scores for plan effectiveness. Recalculated as new execution outcomes are recorded by the Learning Engine (see 08 Agents Catalogue & Platform Services section 3).

plan_score: defineTable({
  canonicalPlanId: v.id("canonical_plan"),
  agentId: v.optional(v.id("agent")),
  scopeKey: v.string(),                    // hierarchical scope: "global" | "account:X" | "agent:Y" | "context:Z"
  score: v.number(),                       // 0.0 to 1.0
  sampleCount: v.number(),
  alpha: v.number(),                       // Bayesian prior
  beta: v.number(),
  meanCost: v.number(),
  meanDurationMs: v.number(),
  lastUpdated: v.number(),
})
  .index("by_plan_scope", ["canonicalPlanId", "scopeKey"])
  .index("by_agent", ["agentId"]),

14. Platform services

14.1 platform_service

External service registry. Tracks all third-party services the platform integrates with.

platform_service: defineTable({
  slug: v.string(),
  name: v.string(),
  description: v.string(),
  serviceType: v.union(
    v.literal("llm"),
    v.literal("integration"),
    v.literal("external_tool"),
  ),
  baseUrl: v.string(),
  platformKeyRef: v.optional(v.string()),  // secrets vault reference
  status: v.union(v.literal("active"), v.literal("disabled")),
})
  .index("by_slug", ["slug"])
  .index("by_type", ["serviceType"]),

14.2 llm_model

Available LLM models with pricing.

llm_model: defineTable({
  platformServiceId: v.id("platform_service"),
  slug: v.string(),
  name: v.string(),
  provider: v.string(),
  inputCostPer1k: v.number(),
  outputCostPer1k: v.number(),
  contextWindow: v.number(),
  capabilities: v.any(),
  status: v.union(
    v.literal("active"),
    v.literal("deprecated"),
    v.literal("disabled"),
  ),
})
  .index("by_service", ["platformServiceId"])
  .index("by_slug", ["slug"]),

14.3 account_service_config

Account-level service configuration overrides. Allows accounts to bring their own API keys (BYOK).

account_service_config: defineTable({
  accountId: v.id("account"),
  platformServiceId: v.id("platform_service"),
  credentialsRef: v.string(),              // secrets vault reference
  config: v.any(),
})
  .index("by_account", ["accountId"])
  .index("by_account_service", ["accountId", "platformServiceId"]),

Rules:

  • Allows accounts to use their own API keys with external services.
  • credentialsRef points to an encrypted vault entry; keys are never stored directly in this table. See 07 Security & Governance for the full vault model.

14.4 account_llm_preference

Account-level LLM model preferences by performance tier.

account_llm_preference: defineTable({
  accountId: v.id("account"),
  tier: v.union(v.literal("fast"), v.literal("balanced"), v.literal("powerful")),
  llmModelId: v.id("llm_model"),
})
  .index("by_account_tier", ["accountId", "tier"]),

Rules:

  • Each account specifies a preferred model for each performance tier.
  • Agents use these preferences unless they override at the agent level.

15. Media, storage and libraries

These entities support file storage, processing, and document-grounded knowledge retrieval. They are distinct from the knowledge_fact layer: facts are small, structured, accumulated through interaction; media-derived content is large, document-sourced, loaded deliberately by operators and agents. For the full ingestion pipeline see 05 Persistence, Storage & Ingestion.

15.1 storage_bucket

A registry of all storage buckets available to the platform, covering three tiers: Thinklio-managed shared buckets, Thinklio-managed dedicated enterprise buckets, and account-supplied BYOB (bring your own bucket) buckets.

storage_bucket: defineTable({
  name: v.string(),
  bucketType: v.union(
    v.literal("platform_shared"),
    v.literal("enterprise_dedicated"),
    v.literal("account_supplied"),
  ),
  provider: v.union(
    v.literal("r2"),
    v.literal("s3"),
    v.literal("minio"),
    v.literal("gcs"),
  ),
  endpoint: v.string(),
  bucketName: v.string(),
  jurisdiction: v.union(
    v.literal("AU"),
    v.literal("EU"),
    v.literal("US"),
    v.literal("OTHER"),
  ),
  accountId: v.optional(v.id("account")),  // null for platform_shared
  credentialsRef: v.optional(v.string()),
  validationStatus: v.union(
    v.literal("pending"),
    v.literal("valid"),
    v.literal("invalid"),
  ),
  validatedAt: v.optional(v.number()),
  isActive: v.boolean(),
})
  .index("by_account", ["accountId"])
  .index("by_type", ["bucketType"])
  .index("by_jurisdiction", ["jurisdiction"]),

Bucket types:

  • platform_shared: Managed by Thinklio operations; available to all accounts; credentials held in platform infrastructure. Thinklio guarantees data residency within the declared jurisdiction.
  • enterprise_dedicated: Managed by Thinklio operations; provisioned exclusively for one enterprise account. Used where dedicated infrastructure is required without the account managing the bucket themselves.
  • account_supplied: Registered by the account admin. Any S3-compatible provider. Credentials encrypted into the platform secrets vault at registration; credentialsRef is the vault lookup key. Thinklio accepts the account's jurisdiction declaration on trust.

Credential security: Access key and secret key for account-supplied buckets are never stored in this table. They are stored in the secrets vault; credentialsRef is the only field written here.

Rules:

  • At account creation, the system assigns the nearest-jurisdiction active platform_shared bucket as the default.
  • Enterprise customers may have one or more enterprise_dedicated buckets assigned by Thinklio ops.
  • Any account can register one or more account_supplied buckets via account settings; each undergoes a validation job (PUT/GET/DELETE test) before becoming active.
  • Bucket selection at upload time uses the account's current default bucket; no silent fallback between buckets.

15.2 account_storage_bucket

Links accounts to storage buckets. An account can have multiple active buckets; exactly one is the default for new uploads at any time.

account_storage_bucket: defineTable({
  accountId: v.id("account"),
  bucketId: v.id("storage_bucket"),
  isDefault: v.boolean(),
  assignedBy: v.union(
    v.literal("system"),
    v.literal("ops"),
    v.literal("account_admin"),
  ),
  assignedAt: v.number(),
})
  .index("by_account", ["accountId"])
  .index("by_account_default", ["accountId", "isDefault"]),

Rules:

  • Exactly one default bucket per account at any time (application-enforced via a mutation guard; Convex has no partial unique index, so the mutation that sets isDefault = true first clears any prior default).
  • system assignment happens at account creation; ops assignment is for enterprise-dedicated buckets; account_admin assignment follows a successful BYOB validation.
  • Changing the default does not migrate existing media. New uploads go to the new default; existing media stays in its original bucket (each media record carries bucketId).

15.3 media

The central entity for all files stored in Thinklio. Any file uploaded by a user or created as an artefact by an agent is a media record. Media is a general-purpose service: agent knowledge ingestion, report outputs, user uploads, and agent-generated documents all use the same entity.

media: defineTable({
  accountId: v.optional(v.id("account")),
  scope: v.union(
    v.literal("agent"),
    v.literal("account"),
    v.literal("team"),
    v.literal("user"),
  ),
  scopeId: v.string(),
  agentId: v.optional(v.id("agent")),
  uploadedBy: v.id("user_profile"),
  originalFilename: v.string(),
  storedFilename: v.string(),
  contentType: v.string(),                 // MIME type
  fileSize: v.number(),
  contentHash: v.string(),                 // SHA-256
  bucketId: v.id("storage_bucket"),
  bucketKey: v.string(),
  status: v.union(
    v.literal("pending"),
    v.literal("processing"),
    v.literal("ready"),
    v.literal("failed"),
    v.literal("archived"),
  ),
  keywords: v.array(v.string()),
  metadata: v.any(),
  summary: v.optional(v.string()),
  summaryEmbedding: v.optional(v.array(v.float64())),
  linkedType: v.optional(v.string()),
  linkedId: v.optional(v.string()),
  deletedAt: v.optional(v.number()),
  updatedAt: v.number(),
})
  .index("by_account", ["accountId"])
  .index("by_account_status", ["accountId", "status"])
  .index("by_uploaded_by", ["uploadedBy"])
  .index("by_content_hash", ["contentHash"])
  .index("by_linked", ["linkedType", "linkedId"])
  .vectorIndex("by_summary_embedding", {
    vectorField: "summaryEmbedding",
    dimensions: 1536,
    filterFields: ["accountId", "scope", "scopeId"],
  }),

Processing tiers:

  • Level 1 (automatic): The media record itself: filename, size, type, hash, bucket location. Created for every upload.
  • Level 2 (enrichment): Optional processors governed by account/agent rules. Outputs go to metadata, keywords, summary, and summaryEmbedding. Includes image analysis, content moderation, PDF text extraction, and summarisation.
  • Level 3 (full indexing): Chunking, embedding, and optional fact extraction. Creates library_item records. Triggered explicitly by intent at upload time, by processing rules, or by a recommended_for_indexing signal from the summarise processor.

Rules:

  • The original file in the bucket is immutable. All processing is derived and re-derivable from the stored blob.
  • contentHash enables deduplication: if the same hash already exists within scope, reference the existing blob rather than storing a duplicate.
  • summary and summaryEmbedding together enable useful retrieval without full indexing. Any summarised document is discoverable via semantic search even if never chunked.
  • Deleting a media record removes the bucket object, all derived library_item records, and any knowledge_fact records sourced from this media.
  • Media scope follows the same visibility rules as knowledge_fact.
  • linkedType and linkedId enable polymorphic attachment to entities. A user can attach a document to a task, item, contact, or note.

15.4 media_processor

Platform-registered processor definitions. Each processor is a discrete, independently runnable unit of work that can be applied to a media record. Processors are managed by Thinklio; accounts and users select which processors run via rules, but cannot define new processor types.

media_processor: defineTable({
  slug: v.string(),
  name: v.string(),
  description: v.string(),
  applicableMimeTypes: v.array(v.string()),
  processingTier: v.union(
    v.literal("level_2_enrichment"),
    v.literal("level_3_indexing"),
  ),
  outputTargets: v.array(v.string()),
  dependsOn: v.array(v.string()),
  configSchema: v.any(),
  status: v.union(v.literal("active"), v.literal("deprecated")),
  version: v.string(),
})
  .index("by_slug", ["slug"])
  .index("by_tier", ["processingTier"]),

Rules:

  • dependsOn is evaluated at job dispatch time. A processor whose dependencies have not completed is held in pending until they do.
  • processingTier determines which governance layer controls enablement: Level 2 processors are available by default subject to account policy; Level 3 processors require explicit intent or a qualifying rule.
  • Processors with outputTargets including summary or summaryEmbedding must only run against text-extractable content types.

15.5 media_processing_rule

Governs which processors run on which media, under what conditions. Rules are evaluated when a media record is created or reprocessed. The three-tier governance model mirrors the account/team/agent structure.

media_processing_rule: defineTable({
  scope: v.union(
    v.literal("platform"),
    v.literal("account"),
    v.literal("agent"),
  ),
  scopeId: v.optional(v.string()),
  processorId: v.id("media_processor"),
  name: v.string(),
  mimeTypePattern: v.string(),
  conditions: v.any(),
  processorConfig: v.any(),
  priority: v.number(),
  status: v.union(v.literal("active"), v.literal("disabled")),
  updatedAt: v.number(),
})
  .index("by_scope", ["scope", "scopeId"])
  .index("by_processor", ["processorId"])
  .index("by_scope_priority", ["scope", "scopeId", "priority"]),

Rules:

  • Platform rules apply to all accounts unless overridden at account or agent scope.
  • Account rules apply to all agents within that account.
  • Agent rules apply only to that specific agent.
  • Rules can only narrow processor usage at lower scopes. An account cannot enable a processor that platform governance has disabled.
  • The conditions field supports recommended_for_indexing: true as a trigger condition, enabling the summarise processor to signal Level 3 readiness and have a chunking rule fire automatically, subject to other conditions and governance approval.

15.6 media_processing_job

Tracks each processor run against each media record. One record per processor per media file, updated on retry.

media_processing_job: defineTable({
  mediaId: v.id("media"),
  processorId: v.id("media_processor"),
  ruleId: v.optional(v.id("media_processing_rule")),
  status: v.union(
    v.literal("pending"),
    v.literal("running"),
    v.literal("completed"),
    v.literal("failed"),
    v.literal("skipped"),
  ),
  inputSnapshot: v.any(),
  output: v.any(),
  errorData: v.any(),
  startedAt: v.optional(v.number()),
  completedAt: v.optional(v.number()),
})
  .index("by_media", ["mediaId"])
  .index("by_media_processor", ["mediaId", "processorId"])
  .index("by_status", ["status"]),

Rules:

  • At most one active (pending or running) job per processor per media record.
  • Failed jobs can be retried; the existing record is updated, not replaced.
  • skipped indicates that the processor's MIME type pattern or conditions were not met, or a dependency failed.
  • Output is retained for audit, debugging, and reprocessing purposes.

15.7 library

A named, curated collection of media-derived content available for semantic retrieval. Libraries are the primary knowledge corpus for agents that depend on document-grounded responses (Coach Agent, HR Agent, Knowledge Base Agent, and others).

library: defineTable({
  name: v.string(),
  description: v.string(),
  scope: v.union(v.literal("platform"), v.literal("account")),
  accountId: v.optional(v.id("account")),
  embeddingModel: v.string(),
  chunkStrategy: v.any(),
  status: v.union(v.literal("active"), v.literal("archived")),
  createdBy: v.id("user_profile"),
  updatedAt: v.number(),
})
  .index("by_account", ["accountId"])
  .index("by_scope", ["scope"]),

Rules:

  • Platform libraries are Thinklio-managed and available to all accounts, useful for reusable domain corpora (general coaching methodology, standard HR frameworks, etc.).
  • Account libraries are customer-owned and visible only within that account.
  • All items in a library must use the same embedding model. Changing the model requires reprocessing all items.
  • Archiving a library prevents new items from being added but does not remove existing items; agents connected to an archived library can still retrieve from it.

15.8 library_item

A chunk of content from a media file, stored with an embedding for semantic retrieval. The atomic unit of library knowledge.

library_item: defineTable({
  libraryId: v.id("library"),
  mediaId: v.id("media"),
  accountId: v.optional(v.id("account")),
  chunkIndex: v.number(),
  content: v.string(),
  tokenCount: v.number(),
  embedding: v.array(v.float64()),
  metadata: v.any(),
})
  .index("by_library", ["libraryId"])
  .index("by_media", ["mediaId"])
  .vectorIndex("by_embedding", {
    vectorField: "embedding",
    dimensions: 1536,
    filterFields: ["libraryId", "accountId"],
  }),

Rules:

  • Created by the chunk_and_index processor; not manually inserted.
  • If the source media record is deleted, all derived library_item records are cascade-deleted via the delete mutation.
  • Reprocessing a media file within a library deletes and recreates its items for that library.
  • A media file can contribute items to multiple libraries (a policy document added to both the account library and a specialist compliance library, for example).

15.9 agent_library

Links agents to libraries, governing which libraries are searched during context assembly.

agent_library: defineTable({
  agentId: v.id("agent"),
  libraryId: v.id("library"),
  priority: v.number(),
})
  .index("by_agent", ["agentId"])
  .index("by_library", ["libraryId"])
  .index("by_agent_priority", ["agentId", "priority"]),

Rules:

  • An agent can be connected to multiple libraries.
  • Priority determines search order in context assembly. Higher-priority library results are retrieved first and weighted higher when the token budget forces truncation.
  • agent_catalog.libraryAssignments pre-configures these associations; when an agent is deployed from a template the library connections are created automatically.

16. Access and security

For the full security model, secrets vault design, and governance policy framework see 07 Security & Governance.

16.1 invitation

Account and team invitations.

invitation: defineTable({
  accountId: v.id("account"),
  teamId: v.optional(v.id("team")),
  invitedEmail: v.string(),
  invitedBy: v.id("user_profile"),
  role: v.string(),
  token: v.string(),                       // one-time invitation token
  status: v.union(
    v.literal("pending"),
    v.literal("accepted"),
    v.literal("expired"),
    v.literal("revoked"),
  ),
  expiresAt: v.number(),
})
  .index("by_account", ["accountId"])
  .index("by_token", ["token"])
  .index("by_email", ["invitedEmail"]),

16.2 api_key

API keys for programmatic access.

api_key: defineTable({
  accountId: v.id("account"),
  name: v.string(),
  keyHash: v.string(),                     // hashed, never plaintext
  scope: v.any(),
  status: v.union(v.literal("active"), v.literal("revoked")),
  lastUsedAt: v.optional(v.number()),
  createdBy: v.id("user_profile"),
})
  .index("by_account", ["accountId"])
  .index("by_hash", ["keyHash"]),

16.3 oauth_token

OAuth tokens for external service integrations.

oauth_token: defineTable({
  accountId: v.id("account"),
  platformServiceId: v.id("platform_service"),
  accessTokenRef: v.string(),              // secrets vault reference
  refreshTokenRef: v.optional(v.string()),
  expiresAt: v.number(),
  updatedAt: v.number(),
})
  .index("by_account", ["accountId"])
  .index("by_account_service", ["accountId", "platformServiceId"]),

16.4 webhook_subscription

Webhook subscription configurations for outbound event delivery.

webhook_subscription: defineTable({
  accountId: v.id("account"),
  url: v.string(),
  events: v.array(v.string()),             // event kinds to subscribe to
  secretRef: v.string(),                   // vault reference for signing secret
  status: v.union(v.literal("active"), v.literal("disabled")),
  lastDeliveryAt: v.optional(v.number()),
  lastDeliveryStatus: v.optional(v.string()),
})
  .index("by_account", ["accountId"])
  .index("by_status", ["status"]),

16.5 secret

The metadata record for values stored in the secrets vault. Actual values are encrypted and stored in a separate encrypted payload table referenced by vaultRef. See 07 Security & Governance for the full vault design.

secret: defineTable({
  accountId: v.optional(v.id("account")),
  name: v.string(),
  secretType: v.string(),                  // "api_key" | "oauth_token" | "webhook_secret" | ...
  url: v.optional(v.string()),
  username: v.optional(v.string()),
  vaultRef: v.string(),                    // opaque reference to encrypted value
  createdBy: v.id("user_profile"),
  rotatedAt: v.optional(v.number()),
})
  .index("by_account_name", ["accountId", "name"])
  .index("by_type", ["secretType"]),

17. Billing and resource management

17.1 credit_ledger

Credit transaction log. Every credit and debit is recorded as an immutable ledger entry.

credit_ledger: defineTable({
  accountId: v.id("account"),
  amount: v.number(),                      // smallest currency unit; positive = credit, negative = usage
  balanceAfter: v.number(),
  description: v.string(),
  referenceType: v.optional(v.string()),
  referenceId: v.optional(v.string()),
})
  .index("by_account_created", ["accountId"])
  .index("by_reference", ["referenceType", "referenceId"]),

17.2 budget_limit

Budget limits per scope.

budget_limit: defineTable({
  accountId: v.id("account"),
  scope: v.union(
    v.literal("account"),
    v.literal("team"),
    v.literal("user"),
  ),
  scopeId: v.optional(v.string()),
  monthlyLimit: v.number(),                // smallest currency unit
  currentSpend: v.number(),
  periodStart: v.number(),
  updatedAt: v.number(),
})
  .index("by_account_scope", ["accountId", "scope", "scopeId"]),

17.3 usage_record

Resource consumption records. One record per billable unit of work.

usage_record: defineTable({
  interactionId: v.optional(v.id("interaction")),
  stepId: v.optional(v.id("step")),
  agentId: v.id("agent"),
  userId: v.id("user_profile"),
  teamId: v.optional(v.id("team")),
  accountId: v.id("account"),
  cost: v.number(),
  costDetail: v.any(),
})
  .index("by_account_created", ["accountId"])
  .index("by_interaction", ["interactionId"])
  .index("by_agent", ["agentId"]),

17.4 quality_rating

User feedback ratings on agent interactions.

quality_rating: defineTable({
  interactionId: v.id("interaction"),
  userId: v.id("user_profile"),
  rating: v.number(),                      // 1 to 5
  comment: v.optional(v.string()),
})
  .index("by_interaction", ["interactionId"])
  .index("by_user", ["userId"]),

17.5 platform_config

Global platform configuration. Singleton table (exactly one row).

platform_config: defineTable({
  config: v.any(),                         // feature flags, defaults, limits
  updatedAt: v.number(),
}),

18. Agent tool integration

The core data structures are exposed as native tools in the tool registry. Every agent can use them based on its tool assignments.

18.1 Task tools

Tool slug Type Trust level Description
native_task_create internal low_risk_write Create a task (optionally in a list)
native_task_update internal low_risk_write Update status, priority, assignment, due date
native_task_list internal read List tasks with filters (status, assigned_to, due_date, list)
native_task_search internal read Search tasks by keyword
native_task_complete internal low_risk_write Mark task as done

18.2 Contact tools

Tool slug Type Trust level Description
native_contact_create internal low_risk_write Create a person or organisation
native_contact_update internal low_risk_write Update contact details
native_contact_search internal read Search contacts by name, email, organisation
native_contact_log internal low_risk_write Log an interaction with a contact
native_contact_history internal read Get interaction history for a contact

18.3 Item tools

Tool slug Type Trust level Description
native_item_create internal low_risk_write Create a ticket/request/inquiry
native_item_update internal low_risk_write Update status, assignment, priority
native_item_list internal read List items with filters (type, status, assigned)
native_item_assign internal low_risk_write Assign to user, team, or agent

18.4 Note tools

Tool slug Type Trust level Description
native_note_create internal low_risk_write Create a note, optionally linked to an entity
native_note_search internal read Search notes by keyword

18.5 Tag tools

Tool slug Type Trust level Description
native_tag_apply internal low_risk_write Apply a tag to any entity
native_tag_remove internal low_risk_write Remove a tag from an entity

18.6 Relationship to external integration tools

External tools (tasks_create, tasks_list, crm_search_contacts, etc.) continue to work with external providers (Todoist, HubSpot). The native_* tools operate on the platform's own data.

Accounts can configure sync between native structures and external services:

  • Todoist sync: native tasks to/from Todoist tasks (two-way).
  • HubSpot sync: native contacts to/from HubSpot contacts (two-way).

When sync is active, both the native tool and the external tool write to the same underlying data, with conflict resolution based on last-modified timestamp.

When sync is not active, the native and external tools operate independently. An agent can use native_task_create for internal tasks and tasks_create (Todoist) for externally-managed tasks.

19. API endpoints

All endpoints require authentication and respect scoping rules. Authentication uses Clerk session tokens validated by Convex middleware. Every endpoint resolves to a Convex query, mutation, or action; the HTTP endpoints documented here are thin wrappers for external callers, exposed via convex/http.ts.

19.1 Tasks

GET    /v1/tasks?scope=user&scope_id=...&status=todo&list_id=...
POST   /v1/tasks                        Create task
GET    /v1/tasks/{id}                   Get task
PATCH  /v1/tasks/{id}                   Update task
DELETE /v1/tasks/{id}                   Delete task

GET    /v1/task-lists?scope=user&scope_id=...
POST   /v1/task-lists                   Create list
PATCH  /v1/task-lists/{id}              Update list
DELETE /v1/task-lists/{id}              Archive list

19.2 Task types

GET    /v1/task-types?account_id=...    List task types (system + account-custom)
POST   /v1/task-types                   Create custom task type (admin only)
GET    /v1/task-types/{id}              Get task type
PATCH  /v1/task-types/{id}              Update custom task type (admin only, system types immutable)
DELETE /v1/task-types/{id}              Delete custom task type (admin only, system types immutable)

19.3 Contacts

GET    /v1/contacts?scope=account&scope_id=...&type=person&q=search
POST   /v1/contacts                     Create contact
GET    /v1/contacts/{id}                Get contact with recent interactions
PATCH  /v1/contacts/{id}                Update contact
DELETE /v1/contacts/{id}                Delete contact

POST   /v1/contacts/{id}/interactions   Log an interaction
GET    /v1/contacts/{id}/interactions   List interactions

19.4 Items

GET    /v1/items?scope=account&scope_id=...&type=support_ticket&status=open
POST   /v1/items                        Create item
GET    /v1/items/{id}                   Get item with linked tasks
PATCH  /v1/items/{id}                   Update item
POST   /v1/items/{id}/assign            Assign to user/team/agent

19.5 Item types

GET    /v1/item-types?account_id=...    List item types (system + account-custom)
POST   /v1/item-types                   Create custom item type
GET    /v1/item-types/{id}              Get item type
PATCH  /v1/item-types/{id}              Update custom item type
DELETE /v1/item-types/{id}              Delete custom item type

19.6 Notes

GET    /v1/notes?scope=user&scope_id=...&linked_type=contact&linked_id=...
POST   /v1/notes                        Create note
GET    /v1/notes/{id}                   Get note
PATCH  /v1/notes/{id}                   Update note
DELETE /v1/notes/{id}                   Delete note

POST   /v1/notes/{id}/share             Share note with user or team
DELETE /v1/notes/{id}/share/{share_id}  Revoke a share
GET    /v1/notes/{id}/mentions          List mentions in a note

19.7 Note types

GET    /v1/note-types?account_id=...    List note types (system + account-custom)
POST   /v1/note-types                   Create custom note type
GET    /v1/note-types/{id}              Get note type
PATCH  /v1/note-types/{id}              Update custom note type
DELETE /v1/note-types/{id}              Delete custom note type

19.8 Mentions

GET    /v1/mentions/search?q=...&types=user,contact,task

Returns matching entities across users, contacts, tasks, items, and agents. Used by the UI for autocomplete when typing @ in a note. Results are scoped to the caller's visibility.

19.9 Tags

GET    /v1/tags?account_id=...          List account's tags
POST   /v1/tags                         Create tag
POST   /v1/tags/apply                   { tag_id, entity_type, entity_id }
POST   /v1/tags/remove                  { tag_id, entity_type, entity_id }

19.10 Agent events

POST   /v1/agent-events                 Push a structured event to an agent

The POST /v1/agent-events endpoint provides bidirectional agent communication. External systems and agents can push structured events into the platform, and the platform routes them to the appropriate agent for processing.

Event structure:

{
  "agent_id": "uuid",
  "event_type": "deployment.completed",
  "payload": { ... },
  "source": "github-actions",
  "correlation_id": "optional-tracking-id"
}

Processing: The HTTP action validates the agent exists and the caller has permission, then writes an event into the event table with the provided payload. The agent's reactive subscriptions pick up the event and a workflow starts the non-conversational turn. The agent can take action (create tasks, update items, notify users via user_comm) without requiring a user chat.

Use cases: CI/CD pipeline notifies a DevOps agent of deployment status. Monitoring system alerts an ops agent of threshold breaches. External CRM pushes contact updates to a CRM agent. Scheduled cron triggers a daily digest agent.

20. Agent usage patterns

The following scenarios illustrate how agents use the core data structures in combination.

20.1 Personal assistant

The canonical use case. A personal assistant agent uses all four structures:

  • Tasks: "Remind me to call John tomorrow" creates a task via native_task_create with dueDate.
  • Contacts: "I just met Sarah from Acme at the conference" creates a contact via native_contact_create and logs the meeting via native_contact_log.
  • Notes: "Here are my notes from today's meeting" creates a note via native_note_create linked to attendee contacts.
  • Items: Less common, but "I got a request from the finance team" creates an item via native_item_create.

Daily briefing: the agent queries tasks due today, overdue items, recent contact interactions, and surfaces them in the briefing page.

20.2 Support agent

Primarily works with items and contacts:

  • Inbound email triggers the agent to create an item of type support_ticket with source = "email" and link to the contact.
  • Agent triages: sets priority, assigns to team or user.
  • Assignment creates a task for the assignee: "Investigate billing issue for Acme".
  • Agent logs interactions: "Replied to customer with workaround".
  • Resolution: item transitions through in_progress to resolved to closed.

20.3 CRM agent

Primarily works with contacts and contact interactions:

  • "Log a call with John at Acme, discussed renewal, he's interested in the enterprise plan."
  • Agent creates a contact interaction, updates contact metadata with deal context.
  • Agent can query: "Who haven't we spoken to in 30 days?" via native_contact_search with last interaction filter.
  • Syncs with HubSpot if configured.

20.4 Task management agent

The dedicated task organiser:

  • Creates and manages task lists: "Create a list for the product launch."
  • Reprioritises: "What's the most important thing I should work on today?"
  • Reports: "Give me a summary of what the team completed this week."
  • Reminders: surfaces overdue tasks and approaching deadlines.
  • Reorganisation: move tasks between lists, batch-update priorities.

20.5 HR / onboarding agent

Uses items for requests and tasks for onboarding workflows:

  • New hire notification creates an item of type request.
  • Generates a task list "Onboarding: Jane Smith" with steps (IT setup, badge, training, etc.).
  • Tracks progress, reminds responsible parties, reports to HR admin.

20.6 Meeting agent

Primarily creates notes and tasks:

  • Post-meeting: agent creates a note with meeting summary linked to attendee contacts.
  • Extracts action items and creates tasks assigned to the responsible people.
  • Logs the meeting as a contact interaction for each attendee.

20.7 Custom and external agents

Third-party agents built on the Thinklio platform use the same tools:

  • A custom "Legal Review" agent creates items of type approval and tracks document review tasks.
  • A custom "Inventory" agent uses tasks to track restock actions and contacts to manage supplier relationships.
  • An external integration creates items via the HTTP API when events occur in their system.

The tool and API interfaces are identical for built-in and custom agents. No special access or different data model is needed.

21. Data lifecycle

21.1 Retention policies

Data type Default retention Configurable Archive strategy
Events 90 days active, then archived Per account Archive to R2 (JSONL per day per account)
Interactions 90 days active Per account Archive to R2
Steps 90 days active Per account Archive to R2
Jobs 90 days active (from terminal state) Per account Archive to R2
Subjobs 90 days active (follows parent job) Per account Archive to R2
Tasks Indefinite (unless deleted) Per account Soft delete with TTL
Contacts Indefinite (unless deleted) Per account Soft delete with TTL
Items Indefinite (unless deleted) Per account Soft delete with TTL
Notes Indefinite (unless deleted) Per account Soft delete with TTL
Knowledge facts Indefinite Per account Soft delete with TTL
Audit logs 2 years minimum Per account (min 2 years) Fivetran CDC to long-term warehouse
User data Until deletion request N/A Hard delete with compliance log

21.2 Data deletion (GDPR right to erasure)

When a user requests deletion (Workflow-orchestrated for durability):

  • User knowledge facts: hard deleted.
  • User's messages in events: anonymised (content replaced, user_id nullified).
  • User's interactions and steps: anonymised.
  • User's job observer registrations: removed.
  • Team knowledge contributions: remain, attributed to "former member".
  • User's tasks, items, notes: marked as deleted and retained per retention policy.
  • Deletion logged in compliance audit trail.

21.3 Sync architecture (future)

For accounts that use external services alongside native structures:

  • Native task to/from Todoist task.
  • Native contact to/from HubSpot contact.
  • Native item to/from Zendesk ticket.

Sync principles:

  • Last-write-wins with configurable conflict resolution.
  • Sync is per-account, per-service, opt-in.
  • External ID stored in sourceRef to prevent duplicates.
  • Sync runs on a schedule (Convex crons) or via webhooks (push).
  • Sync failures are logged but do not block the native operation.

Implementation: A Convex scheduled function reads change events on native entities and pushes to the external service. Inbound changes arrive via webhooks (HTTP actions) or polling and update the native entity. The metadata field stores sync state (last sync timestamp, external version).

This is a Phase 2+ feature. Phase 1 operates native and external independently.

22. Open questions

  • Type system extensibility. The system-reserved vs account-custom type pattern is consistent across task_type, item_type, and note_type. Should there be a single generic entity_type table with a domain discriminator (task, item, note), or is the per-domain table approach preferable for query simplicity and schema clarity?
  • Contact deduplication. With contacts arriving from multiple sources (manual, agent, import, integration), deduplication logic needs design. Options: exact email match, fuzzy name match with human confirmation, agent-assisted merge suggestions.
  • Note versioning. The current model tracks editedAt but not a full version history. Should note edits be versioned (git-style diffs, snapshot-based), and if so, what is the storage and query cost?
  • Media processing cost attribution. Level 2 and Level 3 media processing consume LLM tokens and compute. Should processing costs be attributed to the uploading user/agent, or spread across the account as infrastructure cost?
  • Tag hierarchy. The current tag model is flat. Should tags support hierarchy (parent/child) or namespacing for large accounts with many tags?
  • Aggregate component usage. The Aggregate Convex component can maintain rolling counts (open tasks per account, revenue per month) incrementally. Worth evaluating where explicit aggregates would replace on-read COUNT queries as the dataset grows.

23. Revision history

Date Change
2026-03-14 Original Data Model (old doc 05 v0.1.0) published.
2026-03-21 Core Data Structures: Tasks, Contacts, Items & Notes (old doc 32 v0.2.0) published.
2026-03-21 Data Model updated to v0.3.0 incorporating doc 32 entities.
2026-04-16 Consolidated docs 05 and 32 into this document (v1.0.0). All sources archived.
2026-04-17 Full rewrite for Convex-first representation (v2.0.0). Every entity expressed as a Convex defineTable with validators and indexes matching convex/schema.ts. SQL-flavoured representation preserved under archive/legacy-sql-data-model.md. Conceptual model, relationships, scope and visibility rules, and lifecycle semantics unchanged.