Sprinter Platform

@sprinterai/runtime

Agent execution, tool registry, chat handler, workflows, evals, memory, and guardrails.

Install

pnpm add @sprinterai/runtime

Peer dependency: @sprinterai/core and ai (AI SDK v6).

Overview

@sprinterai/runtime is the execution engine. It takes the types and interfaces defined in core and provides the machinery to run agents, execute tools, handle chat, compile workflows, run evals, manage memory, and enforce guardrails.

Agent System

Agent Registry

import { AgentRegistry } from "@sprinterai/runtime";

const registry = new AgentRegistry();

// Register agents (from code or loaded from DB)
registry.register(analystAgent);
registry.register(researcherAgent);

// Look up by slug
const agent = registry.get("analyst");

Agent Resolution

resolveAgent takes an agent definition and resolves it into a fully configured agent ready for execution -- prompt assembled, tools resolved, permissions applied.

import { resolveAgent } from "@sprinterai/runtime";

const resolved = await resolveAgent({
  agent: analystDefinition,
  stores,
  permissions: userPermissions,
  context: { tenantId, entityId },
});
// resolved.prompt, resolved.tools, resolved.model

Agent Execution

import { executeAgentStream, executeAgentGenerate } from "@sprinterai/runtime";

// Streaming execution (for chat)
const stream = await executeAgentStream({
  agent: resolvedAgent,
  messages,
  onToolCall: (call) => console.log("Tool:", call.name),
});

// One-shot execution (for background tasks)
const result = await executeAgentGenerate({
  agent: resolvedAgent,
  messages,
});
// result.text, result.toolCalls, result.usage

Prompt Building

import { buildAgentPrompt } from "@sprinterai/runtime";
import type { PromptSection } from "@sprinterai/runtime";

const sections: PromptSection[] = [
  { role: "system", content: agent.systemPrompt },
  { role: "context", content: `Tenant: ${tenantName}` },
  { role: "memory", content: memoryPrompt },
];

const prompt = buildAgentPrompt(sections);

Agent Delegation

import { createDelegateToAgentTool } from "@sprinterai/runtime";

const delegateTool = createDelegateToAgentTool({
  agentRegistry: registry,
  stores,
  permissions,
});
// Agents can use this tool to delegate tasks to other agents

Tool System

Tool Registry

import { ToolRegistry } from "@sprinterai/runtime";

const tools = new ToolRegistry();
tools.register(roiCalculator);
tools.register(dealScorer);

// Convert to AI SDK tool set
const aiToolSet = tools.toAIToolSet(permissions);

Tool Execution

import { executeTool } from "@sprinterai/runtime";

const result = await executeTool({
  tool: roiCalculator,
  input: { investment: 1000000, returns: 1500000 },
  permissions: userPermissions,
});
// result.output, result.error, result.duration

Entity Tools

Built-in tools for entity CRUD, search, relations, and stats:

import { createEntityTools } from "@sprinterai/runtime";

const entityTools = createEntityTools(stores.entity, permissions);
// Provides: searchEntities, getEntity, createEntity, updateEntity,
//           deleteEntity, createRelation, listEntityTypes, getEntityStats

AI Bridge

Convert platform tools to AI SDK tools:

import { toAITool, toAIToolSet } from "@sprinterai/runtime";

// Single tool
const aiTool = toAITool(roiCalculator);

// Full set with permission filtering
const toolSet = toAIToolSet(allTools, permissions);

Resolving Agent Tools

import { resolveAgentTools, buildToolGroupMap } from "@sprinterai/runtime";

const tools = await resolveAgentTools({
  agent: agentDefinition,
  stores,
  permissions,
  includeEntityTools: true,
  includeDelegation: true,
});

// Build a slug → ToolDefinition map for a set of groups
const groupMap = buildToolGroupMap(tools, ["crm", "research"]);

Tool Hydration (DB-Stored Tools)

hydrateToolDefinition converts a ToolDefinitionRecord (loaded from the database) into a runnable ToolDefinition. The caller supplies a ToolExecutor that controls how stored code is sandboxed.

import {
  hydrateToolDefinition,
  hydrateToolDefinitions,
} from "@sprinterai/runtime";
import type { ToolExecutor, HydrateToolOptions } from "@sprinterai/runtime";

// Implement ToolExecutor with your preferred sandbox
const myExecutor: ToolExecutor = {
  async execute(code: string, input: unknown): Promise<unknown> {
    // e.g. isolated-vm, Deno subprocess, edge function call
    return runInSandbox(code, input);
  },
};

const options: HydrateToolOptions = {
  executor: myExecutor,
  defaultGroups: ["custom"], // optional
};

// Single record
const tool = hydrateToolDefinition(toolRecord, options);

// Multiple records — disabled records are skipped automatically
const tools = hydrateToolDefinitions(toolRecords, options);

Tools without execute_code in the DB record get a no-op execute that returns a metadata message rather than failing. This lets metadata-only tool definitions participate in tools/list responses without needing runnable code.

HydrateToolOptions fieldTypeDescription
executorToolExecutorRequired. Runs stored code in a sandbox.
defaultGroupsstring[]Optional. Groups assigned to all hydrated tools.

MCP Server

@sprinterai/runtime provides a complete MCP (Model Context Protocol) JSON-RPC 2.0 server implementation. Any Sprinter Platform app can expose its tools over MCP with minimal wiring.

createMcpRouteHandler

Factory that returns a per-request handler. Call it once at module level; invoke it inside your route handler.

import { createMcpRouteHandler } from "@sprinterai/runtime";
import type {
  CreateMcpRouteHandlerConfig,
  McpRequestContext,
} from "@sprinterai/runtime";

// Configure once (module scope)
const handleMcp = createMcpRouteHandler({
  serverName: "my-app",       // reported in initialize response
  serverVersion: "1.0.0",
  protocolVersion: "2024-11-05",
});

// Next.js route handler
export async function POST(request: Request) {
  // Auth is the caller's responsibility
  const auth = await validateApiKeyAuth(supabase, request.headers.get("Authorization"));
  if (!auth) return Response.json({ error: "Unauthorized" }, { status: 401 });

  const tools = await loadToolsForTenant(auth.tenantId);

  return handleMcp(request, {
    tools,                     // ToolDefinition[] — already tenant-scoped
    permissions: auth.scopes as AppPermission[],
    executeOptions: {
      tenantId: auth.tenantId,
      source: "api",
    },
  });
}

McpRequestContext fields:

FieldTypeDescription
toolsToolDefinition[]Tools available for this request (caller filters by tenant, feature flags, etc.).
permissionsAppPermission[]Caller's permissions — buildMcpToolList uses these to filter the advertised tool list.
executeOptionsPartial<ExecuteToolOptions>Passed through to executeTool (tenant ID, tool store, source, etc.).

Handled MCP methods:

MethodBehaviour
initializeReturns protocol version, capabilities, and server info.
tools/listReturns McpToolInfo[] filtered by caller permissions; schemas are JSON Schema (not Zod).
tools/callExecutes the named tool via executeTool; returns MCP content format.
Any otherReturns JSON-RPC -32601 Method not found.

buildMcpToolList / buildMcpToolExecutor

Lower-level building blocks used internally by createMcpRouteHandler. Useful when you need finer control over the request/response cycle.

import {
  buildMcpToolList,
  buildMcpToolExecutor,
} from "@sprinterai/runtime";
import type { McpToolInfo, McpContent, McpCallResult } from "@sprinterai/runtime";

// Filtered tool list with JSON Schema inputSchema
const toolList: McpToolInfo[] = buildMcpToolList(tools, permissions);

// Executor function that returns MCP protocol format
const executor = buildMcpToolExecutor(tools, executeOptions);
const result: McpCallResult = await executor("my-tool-slug", { key: "value" });
// result.content: McpContent[]  — [{ type: 'text', text: '...' }]
// result.isError?: boolean

Chat Handler

Create a Next.js route handler for AI chat:

import { createChatRouteHandler } from "@sprinterai/runtime";

const handler = createChatRouteHandler({
  agentRegistry: registry,
  stores,
  resolvePermissions: async (userId) => getUserPermissions(userId),
  resolveAgent: async (agentSlug) => registry.get(agentSlug),
});

export async function POST(request: Request) {
  const { stream } = await handler.handle(request);
  // toUIMessageStreamResponse() streams full UIMessage parts:
  // text tokens, tool-input, and tool-result -- making tool calls
  // visible to the client UI. Do NOT use toTextStreamResponse(),
  // which only streams raw text and hides tool calls.
  return stream.toUIMessageStreamResponse();
}

Message Utilities

import {
  validateMessage,
  sanitizeContent,
  extractTextFromParts,
  toAIMessages,
} from "@sprinterai/runtime";

// Validate incoming message
const valid = validateMessage(rawMessage);

// Convert DB messages to AI SDK format
const aiMessages = toAIMessages(dbMessages);

Inbox Helpers

@sprinterai/runtime ships six pure data-transformation helpers for building inbox UIs. All are exported from the root barrel:

import {
  buildConversationList,
  computeUnreadCount,
  getOldestReadAt,
  parseMentions,
  filterRespondingAgents,
  buildNotificationInserts,
} from "@sprinterai/runtime";
import type {
  InboxParticipantRow,
  InboxMessageRow,
  InboxUnreadMessageRow,
  InboxChatRow,
  InboxUserParticipantRow,
  NotificationInsert,
} from "@sprinterai/runtime";

The row types are storage-agnostic. Any backend (Supabase, in-memory, etc.) maps its query results to these shapes before calling the helpers.

buildConversationList

Assembles a ConversationListItem[] from raw query rows. Combines participant data, last message preview, and per-chat unread counts in one pass.

const conversations = buildConversationList({
  participantRows,   // InboxUserParticipantRow[] — the user's chat memberships with last_read_at
  allParticipants,   // InboxParticipantRow[]     — all participants across those chats
  recentMessages,    // InboxMessageRow[]          — most recent message per chat
  unreadMessages,    // InboxUnreadMessageRow[]    — messages newer than oldest last_read_at
  nameMap,           // Map<string, string>        — participantId -> display name
  userId,            // string                     — current user's ID
});
// ConversationListItem[] sorted by the order of participantRows

computeUnreadCount

Returns the number of distinct chats with at least one unread message for a given user. Skips messages sent by the user themselves.

const unreadChats = computeUnreadCount(
  participations,    // Array<{ chat_id: string; last_read_at: string }>
  recentMessages,    // InboxUnreadMessageRow[]
  userId,
);
// number — count of chats with unread messages

getOldestReadAt

Finds the oldest last_read_at timestamp across all participant rows. Use this as a query bound so only messages newer than this timestamp need to be fetched, minimising data transfer.

const cutoff = getOldestReadAt(participantRows);
// string | null — ISO timestamp, or null if rows is empty

parseMentions

Extracts unique @mention slugs from a message string using the pattern @([a-z0-9-]+).

const slugs = parseMentions("Hey @analyst and @researcher, please review");
// ["analyst", "researcher"]

filterRespondingAgents

Returns agents that should respond to a message: those with auto_respond: true or whose slug appears in the mentioned list. Results are deduped.

const responders = filterRespondingAgents(agents, mentionedSlugs);
// agents that are auto_respond OR explicitly @mentioned, no duplicates

buildNotificationInserts

Builds NotificationInsert rows for all user participants except the sender. Generates a mention-aware title ("Alice mentioned you" vs "New message from Alice") and a content preview capped at 100 characters.

const inserts = buildNotificationInserts({
  participants,       // Array<{ participant_type, participant_id }>
  senderId,
  senderName,
  tenantId,
  chatId,
  content,
  mentionedSlugs,     // string[] from parseMentions()
  agentSlugToId,      // Map<string, string> — slug -> participant UUID
  tenantSlug,         // optional; omit or pass 'default' for bare /inbox/{chatId} links
});
// NotificationInsert[] — one row per non-sender user participant

The caller is responsible for persisting the returned rows (e.g. inserting into the notifications table).

Workflows

Compile Entity Workflows

import { compileEntityWorkflowDefinition, getWorkflowLayers } from "@sprinterai/runtime";

// Compile from entity type field configs into a DAG
const definition = compileEntityWorkflowDefinition(entityType);

// Get execution layers (parallel groups respecting dependencies)
const layers = getWorkflowLayers(definition);
// [[node1, node2], [node3], [node4, node5]]

Workflow Progress

import { computeWorkflowProgress, deriveWorkflowStatus } from "@sprinterai/runtime";

const progress = computeWorkflowProgress(nodeRuns);
// { total: 5, completed: 3, failed: 0, running: 1, pending: 1, percent: 60 }

const status = deriveWorkflowStatus(nodeRuns);
// "running" | "completed" | "failed" | "pending"

Workflow Run Queries

Higher-level query helpers that build on WorkflowRunStore.listWorkflowRuns. All functions accept a store instance already scoped to the correct tenant.

import {
  getWorkflowRunHistory,
  getActiveWorkflowRuns,
  getRecentWorkflowRuns,
  getWorkflowActivitySummary,
} from "@sprinterai/runtime";
import type { WorkflowActivitySummary } from "@sprinterai/runtime";
FunctionDescription
getWorkflowRunHistory(store, entityId, { limit? })Run history for a specific entity, most-recent first. Default limit: 20.
getActiveWorkflowRuns(store, tenantId, { limit? })All running, waiting_human, and partial runs for a tenant, sorted by start time. Default per-status limit: 50.
getRecentWorkflowRuns(store, tenantId, limit?)Recently completed or failed runs, sorted by completion time. Default limit: 20.
getWorkflowActivitySummary(store, entityId)Aggregate counts (active, waitingHuman, completedToday, failedToday) for an entity.

WorkflowActivitySummary shape:

interface WorkflowActivitySummary {
  active: number;          // running + waiting_human + partial
  waitingHuman: number;    // subset of active awaiting human input
  completedToday: number;  // completed since midnight UTC
  failedToday: number;     // failed since midnight UTC
}

Workflow Claim Manager

Utilities for managing node-run claims — the distributed lock that prevents two workers from executing the same node simultaneously.

import {
  expireStaleWorkflowNodeClaims,
  hasActiveClaims,
  findStaleNodeRuns,
  DEFAULT_CLAIM_TIMEOUT_MS,
} from "@sprinterai/runtime";

DEFAULT_CLAIM_TIMEOUT_MS — 15 minutes (900_000 ms). Claims held longer than this are considered stale.

expireStaleWorkflowNodeClaims(store, maxDurationMs?) — expires all claims older than maxDurationMs by delegating to store.expireStaleNodeClaims. Returns the count of claims expired. Call this from a periodic background job.

const expired = await expireStaleWorkflowNodeClaims(workflowRunStore);
// expired: number — how many stale claims were released

hasActiveClaims(nodeRuns) — pure predicate. Returns true if any node run in the array has status === 'running' and a non-null claimed_by. Use to decide whether a workflow can advance.

findStaleNodeRuns(nodeRuns, maxDurationMs?) — filters a node-run array to entries whose claimed_at timestamp is older than the cutoff. Useful for monitoring dashboards or manual expiry flows.

const stale = findStaleNodeRuns(nodeRuns, 5 * 60_000); // 5-minute threshold

Document Operations

@sprinterai/runtime provides a set of store-agnostic document operation functions. Each function accepts a narrow store interface so any backend (Supabase, in-memory, S3-backed) can implement the surface independently.

import {
  uploadDocument,
  getDocumentPages,
  getDocumentPage,
  searchDocumentPages,
  searchAllDocuments,
  linkDocumentToEntity,
  unlinkDocumentFromEntity,
  deleteDocument,
  getDocumentUrl,
  DOCUMENT_RELATIONSHIP_TYPE,
  DEFAULT_URL_EXPIRY_SECONDS,
} from "@sprinterai/runtime";

Upload

import type { UploadDocumentStore } from "@sprinterai/runtime";

const doc = await uploadDocument(store, {
  tenantId,
  title: "Q3 Report",
  fileData: buffer,
  mimeType: "application/pdf",
});
// doc: DocumentRecord

UploadDocumentStore requires a single method: uploadDocument(input: UploadDocumentInput): Promise<DocumentRecord>. File storage is handled entirely by the store implementation.

Pagination

import type { DocumentPageStore } from "@sprinterai/runtime";

// All pages, sorted by page_number ascending
const pages = await getDocumentPages(store, documentId);

// A specific page (1-based)
const page = await getDocumentPage(store, documentId, 3);
// null if the page does not exist

DocumentPageStore requires getDocumentChunks(documentId): Promise<DocumentChunkRecord[]>.

import type { DocumentSearchStore } from "@sprinterai/runtime";

// Search within one document
const results = await searchDocumentPages(store, documentId, "revenue forecast", { limit: 10 });

// Search across all documents for a tenant, optionally restricting to a subset
const results = await searchAllDocuments(store, tenantId, "due diligence", {
  limit: 25,
  documentIds: ["doc-a", "doc-b"], // optional — applied client-side post-query
});

DocumentSearchStore requires searchDocumentChunks(query, options?): Promise<DocumentChunkRecord[]>.

Linking

Documents are linked to entities via the entity-relation mechanism using DOCUMENT_RELATIONSHIP_TYPE = 'document'. The document record must have a non-null entity_id for the link to be created.

import type { DocumentLinkStore } from "@sprinterai/runtime";

// Create a link: entity -> document entity
await linkDocumentToEntity(store, documentId, entityId, tenantId);

// Remove the link
await unlinkDocumentFromEntity(store, documentId, entityId, tenantId);

DocumentLinkStore requires getDocument, createEntityRelation, and deleteEntityRelation.

Deletion

import type { DocumentDeletionStore } from "@sprinterai/runtime";

await deleteDocument(store, documentId);
// Cascades to chunks/pages and removes the storage file

DocumentDeletionStore requires deleteDocumentWithFile(documentId): Promise<void>.

URL Generation

import type { DocumentUrlStore } from "@sprinterai/runtime";

// Default expiry: 1 hour (DEFAULT_URL_EXPIRY_SECONDS = 3600)
const url = await getDocumentUrl(store, documentId);

// Custom expiry
const url = await getDocumentUrl(store, documentId, 300); // 5 minutes

DocumentUrlStore requires getDownloadUrl(documentId, expiresIn?): Promise<string>.

Store interface summary

InterfaceRequired methods
UploadDocumentStoreuploadDocument(input)
DocumentPageStoregetDocumentChunks(documentId)
DocumentSearchStoresearchDocumentChunks(query, options?)
DocumentLinkStoregetDocument(id), createEntityRelation(input), deleteEntityRelation(input)
DocumentDeletionStoredeleteDocumentWithFile(documentId)
DocumentUrlStoregetDownloadUrl(documentId, expiresIn?)

Analytics

@sprinterai/runtime provides the analytics recording infrastructure. Import from the root barrel:

import { createAnalyticsRecorder, createAnalyticsHooks } from "@sprinterai/runtime";
import type { AnalyticsHooks } from "@sprinterai/runtime";

createAnalyticsRecorder

Creates a fire-and-forget recordEvent function that fans out to multiple providers. Provider errors are caught and optionally reported — they never propagate to the caller.

import { createAnalyticsRecorder } from "@sprinterai/runtime";
import { SupabaseAnalyticsProvider } from "@sprinterai/supabase";

const recordEvent = createAnalyticsRecorder({
  providers: [new SupabaseAnalyticsProvider(supabaseClient)],
  onError: (provider, error) => console.error("Analytics error:", error),
});

The returned recordEvent(type, metadata, tenantId, userId?) function is synchronous from the caller's perspective.

createAnalyticsHooks

Wraps recordEvent in typed helper methods that emit standard events without requiring raw event strings:

const analytics = createAnalyticsHooks(recordEvent);

// Entity lifecycle
analytics.onEntityMutation("created", entity.id, "company", tenantId, userId);

// Workflow run
analytics.onWorkflowRun("completed", workflowId, tenantId, userId, { duration: 4200 });

// Tool execution
analytics.onToolExecution(toolId, tenantId, userId);

// Agent invocation / delegation
analytics.onAgentInvocation("analyst", tenantId, userId);
analytics.onAgentDelegation("coordinator", "analyst", tenantId, userId);

// Chat
analytics.onChatMessageSent(conversationId, tenantId, userId);

// Search
analytics.onSearchPerformed(query, tenantId, userId, { results: 12 });

AnalyticsHooks is the return type. Event type constants come from ANALYTICS_EVENTS in @sprinterai/core.

View Permission Enforcer

createViewPermissionEnforcer wraps any ViewStore with scope-based authorization. Reads are filtered silently; writes throw on denial.

import { createViewPermissionEnforcer } from "@sprinterai/runtime";
import type { ViewPermissionEnforcer } from "@sprinterai/runtime";

const enforcer = createViewPermissionEnforcer(stores.view, {
  tenantId,
  userId,
  permissions: userPermissions,
});

// Returns only views the caller can access
const views = await enforcer.listViews();

// Returns null for inaccessible views (no information leakage)
const view = await enforcer.getView(idOrSlug);

// Throws 'Permission denied' if not authorized
await enforcer.updateView(idOrSlug, { title: "New Title" });

// Throws 'Permission denied' if not authorized
await enforcer.deleteView(idOrSlug);

Access rules mirror the canAccessView / canModifyView pure functions in @sprinterai/core. See View Permissions in core for the scope matrix.

Comment Handler

createCommentHandler adds business logic over any CommentStore: thread building, comment counting, parent validation, and fire-and-forget audit hooks.

import { createCommentHandler } from "@sprinterai/runtime";
import type { CommentHandler } from "@sprinterai/runtime";

const handler = createCommentHandler({
  store: commentStore,
  onCommentCreated: (comment) => auditLog("comment.created", comment.id),
  onCommentUpdated: (comment) => auditLog("comment.updated", comment.id),
  onCommentDeleted: (commentId, entityId) => auditLog("comment.deleted", commentId),
});

// Threaded tree (flat → tree conversion)
const threads = await handler.getThreadedComments(entityId);
// threads[0].replies[0].replies — fully nested

// Flat list
const flat = await handler.getComments(entityId);

// Total comment count
const count = await handler.getCommentCount(entityId);

// Create top-level comment
const comment = await handler.createComment({
  tenantId, entityId, userId,
  content: "Looks good to me",
});

// Create a reply (validates parent exists in the same entity)
const reply = await handler.createComment({
  tenantId, entityId, userId,
  content: "Agreed",
  parentId: comment.id,
});

// Update content
await handler.updateComment(comment.id, "Updated text");

// Delete
await handler.deleteComment(comment.id, entityId);

Audit hooks are fire-and-forget: a failing hook never propagates to the caller.

Pure thread helpers

Also exported from @sprinterai/runtime for use without the handler:

import { buildCommentThread, countComments, getReplyCount } from "@sprinterai/runtime";

// Convert flat CommentRecord[] to ThreadedComment[] (sorted by created_at)
const tree = buildCommentThread(flatComments);

// Total count of all comments in a flat list
const total = countComments(flatComments);

// Number of direct replies to a specific comment
const replies = getReplyCount(flatComments, commentId);

ThreadedComment extends CommentRecord with a replies: ThreadedComment[] field. Orphaned replies (parent not in the list) are promoted to top-level rather than discarded.

Audit Context

createAuditContext creates a scoped audit logger that auto-injects a correlation ID into every entry, enabling related operations to be queried together.

import { createAuditContext, generateCorrelationId } from "@sprinterai/runtime";
import type { AuditContext } from "@sprinterai/runtime";

// New context with a generated correlation ID
const ctx = createAuditContext(auditStore, tenantId);

// Continue an existing trace
const ctx = createAuditContext(auditStore, tenantId, existingCorrelationId);

// Log a single entry (correlationId is injected into entry.details)
const log = await ctx.log({
  table_name: "entities",
  record_id: entityId,
  changed_by: userId,
  operation: "UPDATE",
  details: { fields_changed: ["stage"] },
});

// Log multiple entries sharing the same correlationId
const logs = await ctx.batchLog([
  { table_name: "entities", record_id: id1, changed_by: userId, operation: "UPDATE", details: {} },
  { table_name: "entities", record_id: id2, changed_by: userId, operation: "UPDATE", details: {} },
]);

// Access the generated/provided correlation ID for downstream use
const { correlationId } = ctx;

To retrieve correlated entries later, pass correlationId to AuditStore.listAuditLogs via the AuditLogFilters.correlationId field (see AuditLogFilters in core).

Scoring

import { mean, median, min, max, aggregateScores } from "@sprinterai/runtime";

const scores = [85, 90, 78, 92];
mean(scores);   // 86.25
median(scores); // 87.5
min(scores);    // 78
max(scores);    // 92

// Aggregate across multiple dimensions
const aggregation = aggregateScores(responses, criteriaSet);

Evals

Run evaluation suites against agents or tools:

import { runEval, exactMatch, fuzzyMatch, contains } from "@sprinterai/runtime";

const result = await runEval({
  target: async (input) => agent.generate(input),
  dataset: [
    { input: "What is 2+2?", expected: "4" },
    { input: "Capital of France?", expected: "Paris" },
  ],
  scorers: [exactMatch, fuzzyMatch],
});
// result.scores, result.passed, result.details

Built-in scorers: exactMatch, fuzzyMatch, numericCloseness, contains.

Memory

import { loadMemories, buildMemoryPrompt } from "@sprinterai/runtime";

const memories = await loadMemories({
  store: stores.memory,
  agentSlug: "analyst",
  userId: user.id,
  tenantId,
});

const prompt = buildMemoryPrompt(memories);
// Inject into agent system prompt

Guardrails

import {
  runInputGuardrails,
  runOutputGuardrails,
  runGuardrails,
} from "@sprinterai/runtime";

// Run input guardrails before LLM call
const inputResult = await runInputGuardrails(guardrails.input, userMessage);
if (!inputResult.passed) {
  return inputResult.reason;
}

// Run output guardrails after LLM response
const outputResult = await runOutputGuardrails(guardrails.output, agentResponse);

Adapters

Connect to external agent protocols:

import {
  discoverMCPTools,
  createA2AAgent,
  createOpenClawModel,
  createHTTPAgent,
} from "@sprinterai/runtime";
import type { MCPAdapterConfig } from "@sprinterai/runtime";

// MCP tool discovery — basic
const mcpTools = await discoverMCPTools({
  serverUrl: "http://localhost:3001",
  client: myMcpClient,
});

// MCP tool discovery — multi-server with slug namespacing
// Tool slugs become "mcp-github-search", "mcp-github-list-repos", etc.
// Without serverPrefix they would be "mcp-search", "mcp-list-repos"
const githubTools = await discoverMCPTools({
  serverUrl: "https://mcp.github.example.com",
  client: githubMcpClient,
  serverPrefix: "github",
});

// Agent-to-Agent protocol
const a2aAgent = createA2AAgent({ endpoint: "https://agent.example.com/a2a" });

// OpenClaw model adapter
const model = createOpenClawModel({ apiKey: process.env.OPENCLAW_KEY });

// Generic HTTP agent
const httpAgent = createHTTPAgent({ baseUrl: "https://api.example.com/agent" });

MCPAdapterConfig fields:

FieldTypeDescription
serverUrlstringMCP server URL (for logging; the client handles the connection).
clientMCPClientLikePre-configured MCP client — bring your own transport.
authTokenstringOptional auth token if the server requires it.
serverPrefixstringOptional prefix for slug namespacing. "github" → slugs become mcp-github-{name}. Prevents collisions when multiple servers expose identically named tools.

Entity Import Pipeline

@sprinterai/runtime provides a three-function pipeline for bulk-importing entities from structured sources (CSV, spreadsheets, API payloads).

batchUpsertEntities

Orchestrates the full upsert flow: match rows against existing entities, then create or update in parallel batches of 25.

import { batchUpsertEntities } from "@sprinterai/runtime";
import type { ImportEntityStore, ParsedRow, UpsertOptions, BatchUpsertResult } from "@sprinterai/runtime";

const rows: ParsedRow[] = [
  { title: "Acme Corp", content: { domain: "acme.com", stage: "active" } },
  { title: "Beta Inc",  content: { domain: "beta.io",  stage: "prospect" } },
];

const result: BatchUpsertResult = await batchUpsertEntities(
  store,          // ImportEntityStore
  "account",      // entity type slug
  rows,
  { matchKey: "domain", overwrite: false },
);
// { created: 1, updated: 1, skipped: 0, failed: 0, errors: [] }

ImportEntityStore extends ImportMatchStore with getEntityContent, createEntity, and updateEntity methods.

matchRowsForUpsert

Lower-level function that partitions rows into create, update, and skip buckets. Rows where content[matchKey] is empty are marked skipped. When multiple entities share the same match-key value the first result wins.

import { matchRowsForUpsert } from "@sprinterai/runtime";

const { matched, skippedCount } = await matchRowsForUpsert(store, "account", rows, "domain");
// matched[i].existingId — null means create, string means update
// matched[i].skipped    — true when match-key cell was empty

mergeContent

Deep-merges an incoming content object onto an existing one. When overwrite is false, only keys that are missing or null/undefined in the existing record are filled in. When overwrite is true, any non-null incoming value replaces the existing value (empty strings and 0 are treated as intentional data).

import { mergeContent } from "@sprinterai/runtime";

const merged = mergeContent(
  { stage: "prospect", domain: "acme.com" },
  { stage: "active",   revenue: 5000000 },
  true, // overwrite
);
// { stage: "active", domain: "acme.com", revenue: 5000000 }

Relation Column Resolver

resolveRelationColumns computes aggregated cross-entity values for a batch of source entities in a single call. It is the runtime counterpart to the RelationColumn types in @sprinterai/core.

import { resolveRelationColumns } from "@sprinterai/runtime";
import type { RelationColumnStore } from "@sprinterai/runtime";

const columns = {
  activity_count: {
    label: "Activity Count",
    relatedTypeSlug: "activity",
    relationshipType: "has_activity",
    aggregation: "count",
  },
  last_call: {
    label: "Last Call",
    relatedTypeSlug: "activity",
    relationshipType: "has_activity",
    aggregation: "latest",
    field: "date",
    filters: [{ field: "activity_type", operator: "eq", value: "call" }],
  },
};

const result = await resolveRelationColumns(entityIds, columns, store);
// {
//   "entity-1": { activity_count: 12, last_call: "2026-03-15" },
//   "entity-2": { activity_count:  4, last_call: null },
// }

Supported aggregations:

ModeBehaviour
countNumber of related entities that pass the filters
latestMost recent updated_at (or the specified field parsed as a date)
sum / avg / min / maxNumeric aggregation over content[field] values

RelationColumnStore interface:

interface RelationColumnStore {
  queryRelationsByTarget(targetEntityIds: string[], relationshipType: string): Promise<EntityRelationRow[]>;
  queryRelationsBySource(sourceEntityIds: string[], relationshipType: string): Promise<EntityRelationRow[]>;
  getEntitiesByIds(ids: string[], typeSlug: string): Promise<RelatedEntityRow[]>;
}

All columns in a single resolveRelationColumns call are resolved concurrently via Promise.all. Passing an empty entityIds array or an empty columns map returns {} immediately without issuing any store queries.

See @sprinterai/core for the RelationColumn and FilterRule type definitions.

Email Engine

@sprinterai/runtime provides a provider-agnostic email engine and a set of pre-built HTML template renderers. Import from the ./email subpath:

import { createEmailEngine } from "@sprinterai/runtime/email";
import type { EmailEngine, EmailEngineOptions } from "@sprinterai/runtime/email";

createEmailEngine

Factory that wires an EmailProvider and EmailConfig into a typed engine. All three methods validate their input before delegating to the provider.

import { createEmailEngine } from "@sprinterai/runtime/email";
import type { EmailProvider } from "@sprinterai/core";

// Bring your own provider (e.g. Resend, SendGrid, SMTP adapter)
const myProvider: EmailProvider = {
  async send(message) {
    const res = await resend.emails.send(message);
    return { id: res.data?.id ?? "", success: !res.error };
  },
};

const email = createEmailEngine({
  provider: myProvider,
  config: {
    apiKey: process.env.EMAIL_API_KEY!,
    fromAddress: "noreply@example.com",
    fromName: "My App",
  },
});

EmailEngine interface

interface EmailEngine {
  // Send a raw email. Input is validated against SendEmailInputSchema.
  send(input: SendEmailInput): Promise<SendEmailResult>;

  // Render and send a notification email.
  sendNotification(
    to: string,
    subject: string,
    ctx: NotificationEmailContext,
  ): Promise<SendEmailResult>;

  // Render and send a digest email.
  sendDigest(
    to: string,
    subject: string,
    ctx: DigestEmailContext,
  ): Promise<SendEmailResult>;
}

send runs SendEmailInputSchema.parse(input) before forwarding to the provider. The from address falls back to config.fromAddress when not supplied in the input.

Template renderers

Five standalone HTML renderers are exported from @sprinterai/runtime/email. They return self-contained HTML strings suitable for any email provider.

import {
  renderNotificationEmail,
  renderDigestEmail,
  renderInviteEmail,
  renderTempPasswordEmail,
  renderPasswordResetEmail,
} from "@sprinterai/runtime/email";
FunctionContext typeUse case
renderNotificationEmailNotificationEmailContextActivity alerts, mentions, updates
renderDigestEmailDigestEmailContextDaily / weekly digest of recent items
renderInviteEmailInviteEmailContextNew member invitation with temp credentials
renderTempPasswordEmailTempPasswordEmailContextAdmin-initiated credential reset
renderPasswordResetEmailPasswordResetEmailContextMagic-link password reset

NotificationEmailContext and DigestEmailContext are defined in @sprinterai/core. The three auth-flow context types are defined in the runtime templates module:

interface InviteEmailContext {
  recipientName: string;
  tenantName: string;
  inviterName: string;
  tempPassword: string;
  loginUrl: string;
  email: string;
}

interface TempPasswordEmailContext {
  recipientName: string;
  tenantName: string;
  tempPassword: string;
  loginUrl: string;
  email: string;
}

interface PasswordResetEmailContext {
  recipientName: string;
  tenantName: string;
  resetUrl: string;
}

All renderers HTML-escape every interpolated value. The footer defaults to tenantName, falling back to the APP_NAME environment variable, then "Platform".

EmailProvider interface

EmailProvider is defined in @sprinterai/core and re-exported from @sprinterai/runtime/providers for backwards compatibility. Implement it to connect any delivery service:

import type { EmailProvider } from "@sprinterai/core";

interface EmailProvider {
  send(message: EmailMessage): Promise<SendEmailResult>;
  sendBatch?(messages: EmailMessage[]): Promise<{ ids: string[] }>;
}

See @sprinterai/core for the full email type catalogue.

Model Resolver

@sprinterai/runtime provides the provider-SDK wiring layer on top of the model catalog defined in @sprinterai/core. Import from the root barrel:

import {
  resolveModel,
  getModelConfig,
  listModels,
  getDefaultModel,
  createProviderRegistry,
  MODEL_REGISTRY,
} from "@sprinterai/runtime";
import type {
  ModelConfig,
  ModelFilter,
  ModelFactory,
  ProviderRegistry,
} from "@sprinterai/runtime";

ModelConfig

Extended model entry with provider-SDK-specific fields in addition to the core catalog metadata:

interface ModelConfig {
  id: string;                // Canonical ID, e.g. 'claude-sonnet-4-6'
  provider: ModelProvider;   // Sourced from @sprinterai/core
  modelId: string;           // Provider-specific ID passed to the AI SDK
  displayName: string;
  contextWindow?: number;
  maxOutputTokens?: number;
  supportsVision?: boolean;
  supportsTools?: boolean;
  capabilities?: ModelCapability[]; // Full capability flags from @sprinterai/core
  speedTier?: 'fast' | 'medium' | 'slow';
  reasoningLevel?: 'low' | 'medium' | 'high' | null;
  costPer1kInput?: number;   // USD per 1,000 input tokens
  costPer1kOutput?: number;  // USD per 1,000 output tokens
}

Note: ModelProvider is re-exported from @sprinterai/core — no duplication.

MODEL_REGISTRY

16-entry read-only registry covering all five providers. Entries carry both legacy boolean fields (supportsVision, supportsTools) and the full capabilities array:

ProviderEntries
anthropicclaude-haiku-4-5, claude-sonnet-4-5, claude-sonnet-4-6, claude-opus-4-6
openaigpt-4.1, gpt-4o, gpt-4o-mini, o3, o4-mini
googlegemini-2.0-flash, gemini-2.5-flash, gemini-2.5-pro
deepseekdeepseek-chat, deepseek-reasoner
xaigrok-3, grok-3-mini

resolveModel

Looks up a canonical model ID in MODEL_REGISTRY, then delegates to the matching provider factory:

import { anthropic } from "@ai-sdk/anthropic";
import { openai } from "@ai-sdk/openai";

const providers = createProviderRegistry({
  anthropic: (id) => anthropic(id),
  openai: (id) => openai(id),
  google: (id) => google(id),
});

const model = resolveModel("claude-sonnet-4-6", providers);
// Returns an AI SDK LanguageModel ready for streamText / generateText

Throws if the model ID is unknown or the provider is not registered in the ProviderRegistry.

Utility functions

// Look up a single entry
const config = getModelConfig("gpt-4o");

// List with optional filtering
const visionModels = listModels({ supportsVision: true });
const anthropicModels = listModels({ provider: "anthropic" });

// Get the default model (GPT-4o)
const defaultConfig = getDefaultModel();

For the model catalog (ModelCatalogEntry, FALLBACK_MODEL_CATALOG, groupModelsByProvider, and provider constants), see @sprinterai/core.

In-Memory Stores (Testing)

import {
  InMemoryEntityStore,
  InMemoryAgentStore,
  InMemoryChatStore,
  InMemoryTenantStore,
  InMemoryToolStore,
  InMemoryTaskStore,
  InMemoryMemoryStore,
} from "@sprinterai/runtime";

const stores = {
  entity: new InMemoryEntityStore(),
  agent: new InMemoryAgentStore(),
  chat: new InMemoryChatStore(),
  tenant: new InMemoryTenantStore(),
  tool: new InMemoryToolStore(),
  task: new InMemoryTaskStore(),
  memory: new InMemoryMemoryStore(),
};

Module Loading

import { loadModules, createPlatformRuntime } from "@sprinterai/runtime";

// Load and validate modules
const loaded = loadModules([dealFlow, portfolioModule]);
// loaded.entityTypes, loaded.agents, loaded.tools

// Create full runtime
const runtime = createPlatformRuntime({
  modules: [dealFlow, portfolioModule],
  stores: supabaseStores,
});
// runtime.agents, runtime.tools, runtime.stores

Automation Utilities

@sprinterai/runtime provides schedule display utilities and an AI-assisted automation plan parser. Import from the ./automation subpath:

import {
  cronToHuman,
  getNextRun,
  buildParserSystemPrompt,
  AutomationPlanSchema,
} from "@sprinterai/runtime/automation";
import type { AutomationPlan } from "@sprinterai/runtime/automation";

cronToHuman

Converts a cron expression to a human-readable description. Recognizes 15 common patterns and falls back to interval-pattern parsing before returning the raw string unchanged.

cronToHuman('0 9 * * *');         // "Daily at 9 AM"
cronToHuman('0 9 * * 1-5');       // "Weekdays at 9 AM"
cronToHuman('*/15 * * * *');      // "Every 15 minutes"
cronToHuman('0 */4 * * *');       // "Every 4 hours"
cronToHuman('0 0 1 * *');         // "Monthly on the 1st"
cronToHuman('5 4 2 3 *');         // "5 4 2 3 *"  (unknown, returned as-is)

getNextRun

Computes the next occurrence of a cron schedule. Requires an optional injected parser to keep the runtime package dependency-light:

import { CronExpressionParser } from 'cron-parser';

const nextRun = getNextRun('0 9 * * *', {
  now: new Date(),
  timezone: 'America/New_York',
  parser: CronExpressionParser,
});
// "2026-04-04T13:00:00.000Z"  (ISO string)

// Without a parser, always returns null
getNextRun('0 9 * * *'); // null

AutomationPlan and buildParserSystemPrompt

AutomationPlanSchema is the Zod structured-output schema for AI-assisted automation creation. Feed it as the schema option to generateObject from the AI SDK:

import { generateObject } from 'ai';
import {
  AutomationPlanSchema,
  buildParserSystemPrompt,
} from "@sprinterai/runtime/automation";

const entityTypes = [
  { slug: 'account', name: 'Account', description: 'Portfolio company' },
  { slug: 'contact', name: 'Contact' },
];

const { object } = await generateObject({
  model: yourModel,
  schema: AutomationPlanSchema,
  system: buildParserSystemPrompt(entityTypes),
  prompt: 'Monitor news for our portfolio companies every morning at 9am',
});

// object satisfies AutomationPlan:
// {
//   name: "Portfolio News Monitor",
//   slug: "portfolio-news-monitor",
//   description: "Monitors news for portfolio companies each morning",
//   entityTypeSlug: "account",
//   schedule: "0 9 * * *",
//   scheduleHuman: "Daily at 9 AM",
//   prompt: "Search for recent news...",
//   icon: "search",
// }

buildParserSystemPrompt injects the available entity types into the system prompt so the model can match user intent to the correct data type. When no entity types are configured, the list displays (none configured yet).

See @sprinterai/core for AutomationType, UnifiedAutomation, AutomationRun, and AutomationStore.

Automation Execution Engine

@sprinterai/runtime provides trigger matching and sequential step execution for entity-driven automations. Import from the root barrel:

import {
  matchEntityCreatedTriggers,
  matchFieldChangedTriggers,
  executeAutomationWorkflow,
} from "@sprinterai/runtime";
import type {
  EntityEvent,
  AutomationCandidate,
  TriggerMatch,
  AutomationStepExecutor,
  AutomationRunTracker,
  ExecuteAutomationWorkflowInput,
  ExecuteAutomationWorkflowResult,
} from "@sprinterai/runtime";

Trigger Matching

Both matching functions take an EntityEvent and a list of active AutomationCandidate objects loaded from storage. They return TriggerMatch[] — the automations that should be dispatched.

const event: EntityEvent = {
  entityId: "ent-123",
  tenantId: "ten-abc",
  entityTypeSlug: "account",
  currentContent: { stage: "closed" },
  previousContent: { stage: "active" },
};

// Match entity_created triggers
const created = matchEntityCreatedTriggers(event, automations);

// Match field_changed triggers (requires both previousContent and currentContent)
const changed = matchFieldChangedTriggers(event, automations);
// changed[0].automationId, changed[0].runGroupId

Both functions automatically skip automations that target the automation entity type itself (prevents infinite loops). Automations with content.status !== 'active' are always skipped.

TriggerMatch

interface TriggerMatch {
  automationId: string;
  runGroupId: string;  // Stable idempotency key: "<trigger>:<entityId>:<automationId>:<timestamp>"
}

Trigger config constraints

Trigger typeConstraint fieldBehaviour
entity_createdentity_type_slugSkip if slug doesn't match
entity_createdfilterSkip if any content[key] !== filter[key]
field_changedentity_type_slugSkip if slug doesn't match
field_changedfieldRequired — skip if missing
field_changedto_valueSkip if currentContent[field] !== to_value

executeAutomationWorkflow

Runs automation steps sequentially. A step failure does not abort subsequent steps — all steps are always attempted.

const result = await executeAutomationWorkflow({
  automationId: match.automationId,
  tenantId: "ten-abc",
  triggerType: "field_changed",
  triggerEntityId: "ent-123",
  runGroupId: match.runGroupId,
  steps: automation.content.steps,
  automationTitle: automation.content.title,
  stepExecutor: myStepExecutor,   // AutomationStepExecutor
  runTracker: myRunTracker,       // AutomationRunTracker
});
// {
//   runId: "run-xyz",
//   status: "completed" | "partial" | "failed",
//   stepCount: 3,
//   completedCount: 2,
//   failedCount: 1,
// }

Final status:

  • completed — all steps succeeded
  • partial — some steps succeeded, some failed
  • failed — all steps failed (or no steps ran)

Throws immediately if steps is empty.

AutomationStepExecutor

Injected interface — consumers supply their own implementation (e.g. AI SDK + Supabase):

interface AutomationStepExecutor {
  execute(params: {
    agentSlug: string;
    tenantId: string;
    prompt: string;
    label: string;
    automationTitle: string;
  }): Promise<void>;
}

AutomationRunTracker

Injected interface for run persistence:

interface AutomationRunTracker {
  createRun(params: CreateAutomationRunParams): Promise<{ id: string }>;
  updateStepStatus(
    runId: string,
    stepKey: string,
    status: string,
    details?: { errorMessage?: string; durationMs?: number },
  ): Promise<void>;
  completeRun(
    runId: string,
    status: 'completed' | 'partial' | 'failed',
    durationMs: number,
  ): Promise<void>;
}

stepKey format: "automation_step:<order>" (e.g. "automation_step:1").

See @sprinterai/core for AutomationStep, AutomationContent, and the underlying automation DB types.

Cron Schedule Matching

Pure, zero-dependency cron evaluation for scheduled automation dispatch. Import from the root barrel:

import {
  shouldRunNow,
  filterScheduledAutomations,
} from "@sprinterai/runtime";
import type { CronAutomationCandidate } from "@sprinterai/runtime";

shouldRunNow

Checks whether a 5-field cron expression should fire at a given date/time. Returns false for malformed expressions rather than throwing.

Supported field syntax: * (wildcard), 5 (exact), 1-5 (range), 1,3,5 (list), */5 (step from 0), 1-10/2 (step within range). Day-of-week treats 7 as Sunday.

shouldRunNow('0 9 * * *', new Date('2026-04-04T09:00:00'));   // true
shouldRunNow('0 9 * * 1-5', new Date('2026-04-05T09:00:00')); // false (Saturday)
shouldRunNow('*/15 * * * *', new Date('2026-04-04T09:15:00')); // true
shouldRunNow('0 */4 * * *', new Date('2026-04-04T08:00:00'));  // true
shouldRunNow('bad expression'); // false

The second argument defaults to new Date() when omitted.

filterScheduledAutomations

Filters a list of automation candidates to those that should fire at the given time. Skips automations that are disabled (enabled === false) or already running (lastRunStatus === 'running').

interface CronAutomationCandidate {
  id: string;
  schedule: string;     // 5-field cron expression
  timezone?: string;    // accepted but not applied — pass UTC-normalised `now` for timezone precision
  enabled: boolean;
  lastRunStatus?: string | null;
}

const due = filterScheduledAutomations(automations, now);
// CronAutomationCandidate[] — subset whose schedule matches `now`

Pass now explicitly when running in a background job to ensure consistent evaluation across the batch. When omitted, defaults to new Date().

Vault Import/Export

Orchestration layer for bulk entity export and import in Obsidian-compatible markdown format. Builds on generateEntityMarkdown and parseEntityMarkdown from @sprinterai/core. Import from the root barrel:

import {
  exportToVault,
  importFromVault,
  syncWikilinkRelations,
} from "@sprinterai/runtime";
import type {
  VaultExportInput,
  VaultExportResult,
  VaultExportFile,
  VaultImportInput,
  VaultImportResult,
  SyncWikilinkRelationsResult,
  VaultExportStore,
  EntityUpsertStore,
  WikilinkResolver,
  WikilinkSyncStore,
} from "@sprinterai/runtime";

exportToVault

Fetches entities from a store and serialises each to a markdown file at {typeSlug}/{slug}.md.

const result: VaultExportResult = await exportToVault({
  entityStore,   // VaultExportStore — requires list(tenantId, filters?)
  tenantId,
  entityTypeSlug: "account",  // optional — filter by type
  entityIds: ["id-1", "id-2"], // optional — filter by IDs
  limit: 500,                 // default: 500
});
// result.files: VaultExportFile[]  — { path, content }
// result.count: number

VaultExportStore requires a single list(tenantId, filters?) method. Any store that returns VaultExportEntity[] (title, slug, entity_type_slug, content, tags, description) satisfies the interface.

importFromVault

Two-pass import: parse + upsert, then resolve wikilinks.

Pass 1 — for each file:

  1. Parse YAML frontmatter and body with parseEntityMarkdown
  2. Infer typeSlug from the first path segment if not in frontmatter
  3. Validate against validEntityTypes; push to errors if unknown and skip
  4. Upsert: if an entity with the same title + type exists, update it; otherwise create

Pass 2 — for each entity with body text, call wikilinkResolver.syncRelations concurrently. Wikilink sync errors are collected, not thrown.

const result: VaultImportResult = await importFromVault({
  files: [{ path: "account/acme.md", content: markdownString }],
  tenantId,
  entityStore,         // EntityUpsertStore
  wikilinkResolver,    // optional WikilinkResolver
  validEntityTypes: ["account", "contact"],
  slugify: (title) => mySlugFn(title), // optional — defaults to lowercase + hyphens + timestamp
});
// { created: number, updated: number, skipped: number, errors: Array<{ path, error }> }

EntityUpsertStore requires findByTitleAndType, create, and update. WikilinkResolver requires a single syncRelations(entityId, tenantId, body) method.

syncWikilinkRelations

Standalone function for syncing [[wikilink]] mention relations for a single entity. Safe to call independently of the vault import flow.

const result: SyncWikilinkRelationsResult = await syncWikilinkRelations(
  entityId,
  tenantId,
  bodyText,
  store, // WikilinkSyncStore
);
// { resolved: number, unresolved: string[] }

Algorithm:

  1. Extract wikilink titles with extractWikilinkTitles(body) from @sprinterai/core
  2. Resolve each title to an entity ID concurrently via store.searchByTitle
  3. Remove existing "mention" relations for the entity via store.removeRelations
  4. Upsert new "mention" relations for all resolved IDs

Self-referential wikilinks (entity linking to itself) are skipped and appear in unresolved.

WikilinkSyncStore requires searchByTitle(tenantId, title), upsertRelation(tenantId, fromId, toId, type), and removeRelations(tenantId, fromId, type).

Settings Utilities

import {
  resolveSettings,
  shouldShowDashboardRoleHint,
} from "@sprinterai/runtime/settings";

resolveSettings

Merges an ordered array of settings layers (platform defaults → tenant → user) into a single flat record. Later layers override earlier ones. Nested objects are deep-merged one level; arrays and scalars are replaced entirely.

const merged = resolveSettings([
  { value: { theme: { primary: '#1a1a1a', radius: 8 }, aiLimits: { maxTokens: 10000 } } },
  { value: { theme: { primary: '#3B82F6' } } },            // tenant override
  { value: { theme: { radius: 4 }, aiLimits: { maxTokens: 50000 } } }, // user override
]);
// {
//   theme: { primary: '#3B82F6', radius: 4 },
//   aiLimits: { maxTokens: 50000 },
// }

The merge semantics are intentionally shallow at depth 1 — properties inside a nested object are merged, but the object itself is not recursed further. This is sufficient for all current platform settings shapes (color maps, config blocks) and avoids surprising behavior on deeply-nested JSONB blobs.

shouldShowDashboardRoleHint

Determines whether the first-run role-selection card should appear on the dashboard:

shouldShowDashboardRoleHint(null, 0);                              // true  — new user, no activity
shouldShowDashboardRoleHint({ onboardingCompleted: true }, 0);     // false — already completed
shouldShowDashboardRoleHint({ jobFunction: 'sales' }, 0);          // false — already selected
shouldShowDashboardRoleHint(null, 5);                              // false — has existing activity

See @sprinterai/core for TenantSetting, SettingKey, DashboardPreferences, and SettingsStore.

Template Cloner

createTemplateCloner provides a store-agnostic engine for applying workspace templates and snapshotting tenant state.

import { createTemplateCloner } from "@sprinterai/runtime/template";
import type {
  TemplateCloner,
  TemplateClonerStores,
  TemplateClonerConfig,
} from "@sprinterai/runtime/template";

createTemplateCloner

const cloner = createTemplateCloner({ stores: myClonerStores });

TemplateClonerStores is a minimal interface that accepts any store implementation providing the five required operations:

interface TemplateClonerStores {
  createEntityType(input: { tenantId, slug, name, schema, config?, description?, icon?, color? }): Promise<{ id, slug }>;
  entityTypeExists(tenantId: string, slug: string): Promise<boolean>;
  createView(input: { tenantId, title, description?, entityTypeSlug?, blocks, layout, pageType? }): Promise<{ id }>;
  createEntity(input: { tenantId, typeSlug, title, content, tags? }): Promise<{ id }>;
  updateNavConfig(tenantId: string, navConfig: Record<string, unknown>): Promise<void>;
  updateTheme(tenantId: string, theme: Record<string, unknown>): Promise<void>;
}

applyTemplate

Applies a WorkspaceTemplate to a tenant. Entity types with slugs that already exist in the tenant are skipped rather than duplicated. View entityTypeSlug references are remapped through the slug resolution map built during entity type creation.

const result = await cloner.applyTemplate(template, tenantId, {
  createSampleEntities: true,   // default: false
  updateNavigation: true,       // default: true
  applyTheme: false,            // default: false
});
// {
//   entityTypesCreated: 3,
//   agentsCreated: 0,
//   viewsCreated: 2,
//   sampleEntitiesCreated: 5,
//   navigationUpdated: true,
//   themeUpdated: false,
// }

snapshotTenant

Captures the current tenant state into a template structure suitable for "save workspace as template" flows. The current implementation returns a stub with empty collections — a full implementation reading live stores is planned as follow-up work.

const snapshot = await cloner.snapshotTenant(tenantId, {
  name: 'PE Deal Flow Template',
  description: 'Standard deal flow workspace for PE firms',
});
// snapshot omits id, created_at, updated_at (server-managed fields)

See @sprinterai/core for WorkspaceTemplate, TemplateView, TemplateSampleEntity, TemplateCategory, ApplyTemplateOptions, and ApplyTemplateResult.

Feed System

@sprinterai/runtime provides a complete activity-feed layer: ranking, pagination, delta detection, and daily/multi-day digest generation. All functions accept an ActivityStore instance.

import {
  resolveFeed,
  resolveFeedPage,
  buildFeedDelta,
  resolveDigest,
  resolveMultiDayDigest,
  computeForYouScores,
  sortByScore,
  filterByScore,
  rowToFeedItem,
  rowsToFeedItems,
  extractFeedPreview,
  formatActivityPlainText,
  getTimeRangeStart,
} from "@sprinterai/runtime";
import type {
  FeedItem,
  FeedConfig,
  FeedTimeRange,
  FeedTab,
  FeedDigest,
  DigestConfig,
  RankingInput,
} from "@sprinterai/runtime";

FeedConfig

interface FeedConfig {
  tenantId: string;
  userId: string;
  tab: FeedTab;             // 'all' | 'for-you' | 'digest'
  timeRange: FeedTimeRange; // 'today' | 'yesterday' | 'week' | 'month' | 'all'
  entityId?: string;        // filter to a specific entity
  actorId?: string;         // filter by actor (user or agent)
  action?: string;          // filter by activity action
  limit?: number;           // default: 50
  before?: string;          // ISO timestamp cursor for pagination
  includeImportance?: boolean;
  recencyWeight?: number;   // 0-1, default: 0.7
  importanceWeight?: number; // 0-1, default: 0.3
}

Feed resolution

// Full feed query with optional time range filter
const items = await resolveFeed(activityStore, config);

// With time range applied (uses getTimeRangeStart to set `before` cursor)
const items = await resolveFeedWithTimeFilter(activityStore, config);

// Paginated page (cursor-based)
const page = await resolveFeedPage(activityStore, config);

// Delta — items in newItems that are not in prevItems (for live polling)
const delta = buildFeedDelta(prevItems, newItems);

For-You ranking

The "for-you" tab applies a temporal-decay + importance score so recent and important items rise above older ones:

// Score a batch of items in-place
const ranked = sortByScore(items);

// Filter items below a relevance threshold (0-100)
const filtered = filterByScore(items, 30);

// Low-level: compute a single item's score
const score = computeForYouScores({
  createdAt: item.created_at,
  importance: item.metadata?.importance ?? 0,
  now: new Date(),
  recencyWeight: 0.7,
  importanceWeight: 0.3,
});

Digest

import type { DigestConfig, FeedDigest } from "@sprinterai/runtime";

const digestConfig: DigestConfig = {
  ...feedConfig,
  digestLimit: 10,        // max items in digest
  minScoreThreshold: 20,  // drop low-relevance items
  includeRecent: true,
};

// Last 24 hours
const digest = await resolveDigest(activityStore, digestConfig);
// { date, tenantId, items, summary, emailSubject, emailPreview }

// Multi-day digest
const weekly = await resolveMultiDayDigest(activityStore, digestConfig, 7);

FeedDigest.emailSubject and emailSubject are pre-formatted strings ready to pass to createEmailEngine().sendDigest().

Transformation helpers

// Row → FeedItem (single and batch)
const item  = rowToFeedItem(activityRecord);
const items = rowsToFeedItems(activityRecords);

// Display extraction
const preview  = extractFeedPreview(item);   // short text summary
const actor    = extractActorDisplay(item);  // actor display name
const entity   = extractEntityDisplay(item); // entity title
const plain    = formatActivityPlainText(item); // "Actor action Entity"

ExtractionStore and ActivityStore

Both store interfaces and their associated types are re-exported from the root runtime barrel so consumers need only one import:

import type {
  ExtractionStore,
  ExtractionRunRecord,
  ExtractionResultRecord,
  CreateExtractionRunInput,
  CreateExtractionResultInput,
  ActivityStore,
  ActivityRecord,
  ListActivitiesOptions,
  ActivityGroup,
} from "@sprinterai/runtime";

Supabase implementations (SupabaseExtractionStore, SupabaseActivityStore) are exported from @sprinterai/supabase.

Agent Heartbeat Scheduler

Manages the lifecycle of scheduled agent heartbeat runs. Import from the root barrel:

import {
  createHeartbeatRun,
  completeHeartbeatRun,
  failHeartbeatRun,
  getHeartbeatRunHistory,
  getAllRecentHeartbeatRuns,
  cleanupStaleHeartbeatRuns,
} from "@sprinterai/runtime";
import type {
  CreateHeartbeatRunOptions,
  CompleteHeartbeatRunMetrics,
  HeartbeatRunStore,
} from "@sprinterai/runtime";

Run lifecycle

// Create a run with optional idempotency key (prevents duplicates on scheduler restart)
const run = await createHeartbeatRun(store, agentId, {
  tenantId,
  triggerType: "scheduled",    // "scheduled" | "manual" | "api"
  prompt: "Morning briefing",  // optional override prompt
  idempotencyKey: "2026-04-03-09:00", // optional
});

// Mark completed with optional metrics
await completeHeartbeatRun(store, run.id, {
  tokensUsed: 1200,
  costCents: 3,
  durationMs: 5400,
});

// Mark failed with error info
await failHeartbeatRun(store, run.id, new Error("API timeout"));

History and cleanup

// Run history for a specific agent (newest first, default limit: 20)
const history = await getHeartbeatRunHistory(store, agentId, 50);

// All recent runs across agents for a tenant (default limit: 100)
const recent = await getAllRecentHeartbeatRuns(store, tenantId);

// Expire stale running claims (returns count cleaned up)
const cleaned = await cleanupStaleHeartbeatRuns(store, 15 * 60_000); // 15 min

Agent Versioning

Snapshot and restore agent configurations. Import from the root barrel:

import {
  createAgentVersion,
  getVersionHistory,
  rollbackToVersion,
} from "@sprinterai/runtime";
import type { AgentVersionStore } from "@sprinterai/runtime";
// Create a new version (auto-incremented version number)
const version = await createAgentVersion(store, agentId, {
  agentConfig: currentConfig,
  systemPrompt: "You are...",
  model: "claude-sonnet-4-6",
  changeSummary: "Added portfolio analysis tool",
});

// Version history (newest first, default limit: 20)
const history = await getVersionHistory(store, agentId, 10);

// Roll back — snapshots current config as a new version before overwriting
await rollbackToVersion(versionStore, agentStore, agentId, targetVersionId);

rollbackToVersion creates a snapshot of the current config before applying the rollback, so the rollback itself is reversible via another rollbackToVersion call.

Tool Session Manager

Manage tool sessions — stateful execution contexts shared via a public token. Import from the root barrel:

import {
  createToolSession,
  getToolSession,
  getToolSessionByToken,
  completeToolSession,
  listSessionSubmissions,
} from "@sprinterai/runtime";
import type { CreateToolSessionOptions } from "@sprinterai/runtime";
// Create a session
const session = await createToolSession(store, tenantId, {
  toolSlug: "deal-scorer",
  title: "Q1 Pipeline Review",
  entityIds: ["entity-1", "entity-2"],
  config: { scoreModel: "v2" },
  createdBy: userId,
});
// session.token — shareable public access token

// Retrieve by session ID (tenant-scoped)
const session = await getToolSession(store, sessionId);

// Retrieve by public token (no tenant check — for shared/public links)
const session = await getToolSessionByToken(store, token);

// Complete a session
await completeToolSession(store, sessionId);

// List all submissions for a session
const runs = await listSessionSubmissions(store, sessionId);

Entity Bulk Operations

Process multiple entities in parallel batches of 50 (configurable). Import from the root barrel:

import {
  bulkDeleteEntities,
  bulkAddTags,
  bulkRemoveTags,
  bulkUpdateField,
  DEFAULT_BATCH_SIZE,
} from "@sprinterai/runtime";
import type { BulkOperationResult } from "@sprinterai/runtime";

All functions return BulkOperationResult:

interface BulkOperationResult {
  succeeded: number;
  failed: number;
  errors: Array<{ id: string; error: string }>;
}
// Delete entities in batches
const result = await bulkDeleteEntities(entityIds, store);

// Add tags — merges with each entity's existing tags
const result = await bulkAddTags(entityIds, ["verified", "q1-pipeline"], store);

// Remove tags — no-ops silently for entities that don't have the tag
const result = await bulkRemoveTags(entityIds, ["archived"], store);

// Update a field on all entities (merges into content)
const result = await bulkUpdateField(entityIds, "stage", "due-diligence", store);

// Custom batch size
const result = await bulkDeleteEntities(entityIds, store, 100);

Entity Sharing and Collections

User sharing

Share individual entities with specific users. Import from the root barrel:

import {
  shareEntityWithUser,
  removeEntityShare,
  getEntityShares,
  updateEntityVisibility,
  generateShareToken,
  getEntityByShareToken,
} from "@sprinterai/runtime";
import type {
  EntityShareRole,
  EntityVisibility,
  EntityShareRecord,
  ShareLinkRecord,
  ShareAccessResult,
  UserShareStore,
  ShareStore,
} from "@sprinterai/runtime";
// Grant access — upsert semantics (updates role if already shared)
const share = await shareEntityWithUser(entityId, userId, "editor", userShareStore);

// Revoke access — no-op if not shared
await removeEntityShare(entityId, userId, userShareStore);

// List all shares for an entity
const shares = await getEntityShares(entityId, userShareStore);

// Change entity visibility
await updateEntityVisibility(entityId, "shared", entityStore);
// Visibility values: 'private' | 'shared' | 'tenant' | 'public'
// Generate a shareable link (default role: 'viewer', default expiry: 30 days)
const link = await generateShareToken(entityId, shareStore, {
  role: "viewer",
  expiresInDays: 7,
});
// link.token — embed in a shareable URL

// Resolve a token to entity access
const access = await getEntityByShareToken(token, shareStore);
// null if token unknown, expired, or inactive
// access.entity, access.role, access.shareRecord

Collections

Collections are filtered, scoreable views of entities shared with a user:

import {
  createCollectionShare,
  deleteCollectionShare,
  listCollectionShares,
} from "@sprinterai/runtime";
import type {
  CreateCollectionShareInput,
  CollectionShareRecord,
  CollectionShareStore,
} from "@sprinterai/runtime";

const collection = await createCollectionShare({
  entityTypeSlug: "opportunity",
  userId,
  tenantId,
  filters: [{ field: "stage", operator: "eq", value: "screening" }],
  minScore: 70,
}, store);

const collections = await listCollectionShares(userId, store);
await deleteCollectionShare(collectionId, store);

Entity Relations CRUD

Higher-level helpers for managing entity relations. Import from the root barrel:

import {
  buildRelationInserts,
  createEntityRelation,
  deleteEntityRelation,
  getRelatedEntities,
  syncEntityRelationsFromConfig,
} from "@sprinterai/runtime";
import type {
  EntityRelationCrudStore,
  RelatedEntityWithRelation,
} from "@sprinterai/runtime";
// Create a typed relation between two entities
await createEntityRelation(store, fromEntityId, toEntityId, "has_activity");

// Delete by relation ID
await deleteEntityRelation(store, relationId);

// List all related entities for an entity (with their relation metadata)
const related = await getRelatedEntities(store, entityId);
// related[i].entity, related[i].relation

// Sync from a config map — adds missing, removes stale
await syncEntityRelationsFromConfig(store, entityId, relationConfigs, relationsMap);

buildRelationInserts

Pure helper that builds the relation insert rows without hitting the store. Useful for batch operations:

const inserts = buildRelationInserts(
  relationConfigs,
  relationsMap,
  entityId,
  tenantId,
);
// Array<Omit<EntityRelationRecord, 'created_at'>>

Response Promotion

Accept a response field value as the authoritative entity field value. Import from the root barrel:

import {
  promoteFieldValue,
  promoteFieldValueAdmin,
} from "@sprinterai/runtime";
import type { PromoteFieldValueStores } from "@sprinterai/runtime";
const stores: PromoteFieldValueStores = {
  response: responseStore,
  entity: entityStore,
};

// Validates response exists, belongs to entity, and is not terminal
await promoteFieldValue(stores, responseId, "revenue", entityId);

// Admin bypass — skips status validation
await promoteFieldValueAdmin(stores, responseId, "revenue", entityId);

promoteFieldValue throws if the response does not exist, the response's entity ID does not match entityId, or the response has a terminal status (already superseded or rejected). Use promoteFieldValueAdmin for admin workflows where those checks should not apply.

Memory CRUD

Create and delete user memories. The existing loadMemories and buildMemoryPrompt functions remain unchanged; these complement them with write operations. Import from the root barrel:

import {
  loadMemories,
  buildMemoryPrompt,
  createUserMemory,
  createMemoryAsAgent,
  deleteUserMemory,
} from "@sprinterai/runtime";
// Create a memory attributed to the user
const memory = await createUserMemory(store, userId, "Prefers concise summaries", "user");

// Create a memory attributed to an agent (source set to agent ID)
const memory = await createMemoryAsAgent(store, userId, "User is interested in SaaS deals", agentId);

// Delete a specific memory
await deleteUserMemory(store, memoryId);

Context CRUD

Upsert and delete entries in the shared-context store. The existing context loaders (loadSharedContextPrompt, buildCorrectionsPrompt) remain unchanged. Import from the root barrel:

import {
  upsertSharedContext,
  deleteSharedContext,
  loadSharedContextPrompt,
  buildCorrectionsPrompt,
  buildLessonsPrompt,
  buildRoutingPrompt,
  CONTEXT_KEYS,
} from "@sprinterai/runtime";
import type {
  SharedContextStore,
  SharedContextRecord,
} from "@sprinterai/runtime";
// Upsert by key (creates if not exists, updates if exists)
const record = await upsertSharedContext(
  store,
  "corrections",
  "Always cite sources when referencing market data",
  tenantId,
);

// Delete a context entry
await deleteSharedContext(store, "corrections", tenantId);

CONTEXT_KEYS is a const of the built-in context key names ('corrections', 'lessons', 'routing', etc.) used by the prompt-building helpers.

External Data Receiver

Framework-agnostic webhook receiver for ingesting external metric data. Import from the root barrel:

import { createExternalDataReceiver } from "@sprinterai/runtime";
import type {
  ExternalDataReceiverConfig,
  ReceiveWebhookOptions,
  WebhookReceiveResult,
  ExternalDataReceiver,
} from "@sprinterai/runtime";

createExternalDataReceiver

Factory that wires an ExternalDataStore into a typed receiver. The receiver handles the full ingestion pipeline in a single receiveWebhook call:

  1. Token lookup — resolves the data source by its URL token
  2. Enabled check — rejects pushes to disabled sources
  3. Signature verification — HMAC-SHA256 if signature_secret is set
  4. Payload validation — WebhookPayloadSchema.parse(body)
  5. Bulk insert — calls store.recordDataPoints with all metrics
import type { ExternalDataStore } from "@sprinterai/core";

const receiver = createExternalDataReceiver({ store: externalDataStore });

// In your POST /api/webhooks/:token route handler:
const result = await receiver.receiveWebhook({
  token: params.token,
  rawBody: await request.text(),      // raw string, not parsed
  headers: Object.fromEntries(request.headers), // lowercase keys
});
// { success: boolean; pointsCreated: number; error?: string }

The receiver returns a result object rather than throwing, so callers can map the outcome to the appropriate HTTP status code (200 on success, 400 on bad payload, 401 on bad signature, 404 on unknown token).

extractByPath

JSONPath-style utility for extracting nested values from API polling responses:

import { extractByPath } from "@sprinterai/runtime";

const data = { results: { metrics: [{ key: "rev", value: 100 }] } };
const metrics = extractByPath(data, "results.metrics");
// [{ key: "rev", value: 100 }]

Supports dot-notation paths and array indexing (items[0].value). Returns undefined for missing paths rather than throwing.

See @sprinterai/core for ExternalDataSource, ExternalDataPoint, ExternalDataStore, and all validation schemas.

Source Sync Utilities

Status helpers and a polling engine for source sync scheduling. Import from the root barrel:

import {
  getSourceValidationStatus,
  resolveSourceSyncStatus,
  canRunSourceSync,
  isSourceDue,
  createSourcePoller,
} from "@sprinterai/runtime";
import type {
  ValidationStatus,
  FetchedItem,
  SourceFetchResult,
  SourcePollSummary,
  SourcePollerConfig,
  SourcePoller,
} from "@sprinterai/runtime";

Status helpers

// Derive a validation status from source config fields
const status = getSourceValidationStatus(source);
// 'valid' | 'missing-url' | 'invalid-url' | 'missing-credentials' | ...

// Resolve the overall sync status for display
const syncStatus = resolveSourceSyncStatus(source);
// 'active' | 'paused' | 'error' | 'never-run'

// Can a sync run now? (enabled, not already running, not rate-limited)
const canRun = canRunSourceSync(source);

// Is the source due for its next scheduled poll?
const due = isSourceDue(source);

createSourcePoller

Factory for polling sources on a schedule:

const poller = createSourcePoller({
  store: sourceStore,
  onFetch: async (source) => {
    // Fetch from source.url, return FetchedItem[]
    return items;
  },
  onProcess: async (items, source) => {
    // Process and persist fetched items
  },
});

// Poll a single source
const summary = await poller.pollSource(sourceId);
// { sourceId, itemsFetched, itemsProcessed, durationMs, error? }

// Poll all sources that are currently due
const summaries = await poller.pollAllDueSources(tenantId);

SourcePollerConfig.onFetch and onProcess are injected so the poller itself has no HTTP or storage dependencies — it delegates both to the caller.

On this page

InstallOverviewAgent SystemAgent RegistryAgent ResolutionAgent ExecutionPrompt BuildingAgent DelegationTool SystemTool RegistryTool ExecutionEntity ToolsAI BridgeResolving Agent ToolsTool Hydration (DB-Stored Tools)MCP ServercreateMcpRouteHandlerbuildMcpToolList / buildMcpToolExecutorChat HandlerMessage UtilitiesInbox HelpersbuildConversationListcomputeUnreadCountgetOldestReadAtparseMentionsfilterRespondingAgentsbuildNotificationInsertsWorkflowsCompile Entity WorkflowsWorkflow ProgressWorkflow Run QueriesWorkflow Claim ManagerDocument OperationsUploadPaginationSearchLinkingDeletionURL GenerationStore interface summaryAnalyticscreateAnalyticsRecordercreateAnalyticsHooksView Permission EnforcerComment HandlerPure thread helpersAudit ContextScoringEvalsMemoryGuardrailsAdaptersEntity Import PipelinebatchUpsertEntitiesmatchRowsForUpsertmergeContentRelation Column ResolverEmail EnginecreateEmailEngineEmailEngine interfaceTemplate renderersEmailProvider interfaceModel ResolverModelConfigMODEL_REGISTRYresolveModelUtility functionsIn-Memory Stores (Testing)Module LoadingAutomation UtilitiescronToHumangetNextRunAutomationPlan and buildParserSystemPromptAutomation Execution EngineTrigger MatchingTriggerMatchTrigger config constraintsexecuteAutomationWorkflowAutomationStepExecutorAutomationRunTrackerCron Schedule MatchingshouldRunNowfilterScheduledAutomationsVault Import/ExportexportToVaultimportFromVaultsyncWikilinkRelationsSettings UtilitiesresolveSettingsshouldShowDashboardRoleHintTemplate ClonercreateTemplateClonerapplyTemplatesnapshotTenantFeed SystemFeedConfigFeed resolutionFor-You rankingDigestTransformation helpersExtractionStore and ActivityStoreAgent Heartbeat SchedulerRun lifecycleHistory and cleanupAgent VersioningTool Session ManagerEntity Bulk OperationsEntity Sharing and CollectionsUser sharingShare tokens (public links)CollectionsEntity Relations CRUDbuildRelationInsertsResponse PromotionMemory CRUDContext CRUDExternal Data ReceivercreateExternalDataReceiverextractByPathSource Sync UtilitiesStatus helperscreateSourcePoller