tr]:last:border-b-0',
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableRow({ className, ...props }: React.ComponentProps<'tr'>) {
+ return (
+
+ )
+}
+
+function TableHead({ className, ...props }: React.ComponentProps<'th'>) {
+ return (
+ [role=checkbox]]:translate-y-[2px]',
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCell({ className, ...props }: React.ComponentProps<'td'>) {
+ return (
+ | [role=checkbox]]:translate-y-[2px]',
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function TableCaption({
+ className,
+ ...props
+}: React.ComponentProps<'caption'>) {
+ return (
+
+ )
+}
+
+export {
+ Table,
+ TableHeader,
+ TableBody,
+ TableFooter,
+ TableHead,
+ TableRow,
+ TableCell,
+ TableCaption,
+}
diff --git a/solutions/trustclaw/src/components/ui/tabs.tsx b/solutions/trustclaw/src/components/ui/tabs.tsx
new file mode 100644
index 0000000000..4485e5a2ae
--- /dev/null
+++ b/solutions/trustclaw/src/components/ui/tabs.tsx
@@ -0,0 +1,91 @@
+'use client'
+
+import * as React from 'react'
+import { cva, type VariantProps } from 'class-variance-authority'
+import { Tabs as TabsPrimitive } from 'radix-ui'
+
+import { cn } from '~/lib/utils'
+
+function Tabs({
+ className,
+ orientation = 'horizontal',
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+const tabsListVariants = cva(
+ 'rounded-lg p-[3px] group-data-[orientation=horizontal]/tabs:h-9 data-[variant=line]:rounded-none group/tabs-list text-muted-foreground inline-flex w-fit items-center justify-center group-data-[orientation=vertical]/tabs:h-fit group-data-[orientation=vertical]/tabs:flex-col',
+ {
+ variants: {
+ variant: {
+ default: 'bg-muted',
+ line: 'gap-1 bg-transparent',
+ },
+ },
+ defaultVariants: {
+ variant: 'default',
+ },
+ }
+)
+
+function TabsList({
+ className,
+ variant = 'default',
+ ...props
+}: React.ComponentProps &
+ VariantProps) {
+ return (
+
+ )
+}
+
+function TabsTrigger({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function TabsContent({
+ className,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Tabs, TabsList, TabsTrigger, TabsContent, tabsListVariants }
diff --git a/solutions/trustclaw/src/components/ui/textarea.tsx b/solutions/trustclaw/src/components/ui/textarea.tsx
new file mode 100644
index 0000000000..376927ddbc
--- /dev/null
+++ b/solutions/trustclaw/src/components/ui/textarea.tsx
@@ -0,0 +1,18 @@
+import * as React from 'react'
+
+import { cn } from '~/lib/utils'
+
+function Textarea({ className, ...props }: React.ComponentProps<'textarea'>) {
+ return (
+
+ )
+}
+
+export { Textarea }
diff --git a/solutions/trustclaw/src/components/ui/tooltip.tsx b/solutions/trustclaw/src/components/ui/tooltip.tsx
new file mode 100644
index 0000000000..f05c3783f9
--- /dev/null
+++ b/solutions/trustclaw/src/components/ui/tooltip.tsx
@@ -0,0 +1,57 @@
+'use client'
+
+import * as React from 'react'
+import { Tooltip as TooltipPrimitive } from 'radix-ui'
+
+import { cn } from '~/lib/utils'
+
+function TooltipProvider({
+ delayDuration = 0,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+function Tooltip({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipTrigger({
+ ...props
+}: React.ComponentProps) {
+ return
+}
+
+function TooltipContent({
+ className,
+ sideOffset = 0,
+ children,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+
+ {children}
+
+
+
+ )
+}
+
+export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
diff --git a/solutions/trustclaw/src/env.ts b/solutions/trustclaw/src/env.ts
new file mode 100644
index 0000000000..aca2f3ac75
--- /dev/null
+++ b/solutions/trustclaw/src/env.ts
@@ -0,0 +1,65 @@
+import { createEnv } from '@t3-oss/env-nextjs'
+import { z } from 'zod'
+
+export const env = createEnv({
+ server: {
+ NODE_ENV: z
+ .enum(['development', 'test', 'production'])
+ .default('development'),
+
+ // Better Auth
+ BETTER_AUTH_SECRET: z.string(),
+
+ // Composio API (global key)
+ COMPOSIO_API_KEY: z.string(),
+
+ // Telegram bot (optional - Telegram features disabled when missing)
+ TELEGRAM_BOT_TOKEN: z.string().optional(),
+ TELEGRAM_BOT_USERNAME: z.string().optional(),
+ TELEGRAM_WEBHOOK_SECRET: z.string().optional(),
+
+ // Database
+ DATABASE_URL: z.string().url(),
+
+ // Redis (optional - resumable streams disabled when missing; basic streaming still works)
+ REDIS_URL: z.string().optional(),
+
+ // Cron auth. Required in production so unauthenticated callers can't hit
+ // /api/cron/* endpoints. Vercel auto-injects this when crons are configured
+ // in vercel.json; the trustclaw deploy CLI also generates one on first deploy.
+ CRON_SECRET: z.string(),
+ },
+ client: {
+ NEXT_PUBLIC_APP_URL: z.string().url(),
+ },
+ runtimeEnv: {
+ // Server
+ NODE_ENV: process.env.NODE_ENV,
+ BETTER_AUTH_SECRET: process.env.BETTER_AUTH_SECRET,
+ COMPOSIO_API_KEY: process.env.COMPOSIO_API_KEY,
+ TELEGRAM_BOT_TOKEN: process.env.TELEGRAM_BOT_TOKEN,
+ TELEGRAM_BOT_USERNAME: process.env.TELEGRAM_BOT_USERNAME,
+ TELEGRAM_WEBHOOK_SECRET: process.env.TELEGRAM_WEBHOOK_SECRET,
+ DATABASE_URL: process.env.DATABASE_URL,
+ REDIS_URL: process.env.REDIS_URL,
+ CRON_SECRET: process.env.CRON_SECRET,
+
+ // Client URL resolution:
+ // - dev: derive from PORT so `PORT=3001 pnpm dev` just works
+ // - prod with explicit override: use NEXT_PUBLIC_APP_URL
+ // - on Vercel: fall back to the auto-injected canonical URL so self-hosters
+ // don't need to set anything (VERCEL_PROJECT_PRODUCTION_URL is the
+ // stable production domain; VERCEL_URL is the per-deployment URL)
+ NEXT_PUBLIC_APP_URL:
+ process.env.NODE_ENV === 'development'
+ ? `http://localhost:${process.env.PORT ?? '3000'}`
+ : process.env.NEXT_PUBLIC_APP_URL ??
+ (process.env.VERCEL_PROJECT_PRODUCTION_URL
+ ? `https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`
+ : process.env.VERCEL_URL
+ ? `https://${process.env.VERCEL_URL}`
+ : undefined),
+ },
+ skipValidation: !!process.env.SKIP_ENV_VALIDATION,
+ emptyStringAsUndefined: true,
+})
diff --git a/solutions/trustclaw/src/lib/utils.ts b/solutions/trustclaw/src/lib/utils.ts
new file mode 100644
index 0000000000..fed2fe91e4
--- /dev/null
+++ b/solutions/trustclaw/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from 'clsx'
+import { twMerge } from 'tailwind-merge'
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/solutions/trustclaw/src/server/api/root.ts b/solutions/trustclaw/src/server/api/root.ts
new file mode 100644
index 0000000000..be59b48891
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/root.ts
@@ -0,0 +1,13 @@
+import { router, createCallerFactory } from './trpc'
+import { healthRouter } from './routers/health'
+import { trustclawRouter } from './routers/trustclaw'
+import { toolkitsRouter } from './routers/toolkits'
+
+export const appRouter = router({
+ health: healthRouter,
+ trustclaw: trustclawRouter,
+ toolkits: toolkitsRouter,
+})
+
+export type AppRouter = typeof appRouter
+export const createCaller = createCallerFactory(appRouter)
diff --git a/solutions/trustclaw/src/server/api/routers/health/index.ts b/solutions/trustclaw/src/server/api/routers/health/index.ts
new file mode 100644
index 0000000000..6388f7568d
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/health/index.ts
@@ -0,0 +1,6 @@
+import { router } from '~/server/api/trpc'
+import { ping } from './ping'
+
+export const healthRouter = router({
+ ping,
+})
diff --git a/solutions/trustclaw/src/server/api/routers/health/ping.schema.ts b/solutions/trustclaw/src/server/api/routers/health/ping.schema.ts
new file mode 100644
index 0000000000..cff86b05d7
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/health/ping.schema.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod'
+
+export const pingOutput = z.object({
+ status: z.string(),
+ timestamp: z.string(),
+})
+
+export type PingOutput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/health/ping.ts b/solutions/trustclaw/src/server/api/routers/health/ping.ts
new file mode 100644
index 0000000000..78ed2144ff
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/health/ping.ts
@@ -0,0 +1,5 @@
+import { publicProcedure } from '~/server/api/trpc'
+
+export const ping = publicProcedure.query(() => {
+ return { status: 'ok', timestamp: new Date().toISOString() }
+})
diff --git a/solutions/trustclaw/src/server/api/routers/toolkits/getAuthLink.schema.ts b/solutions/trustclaw/src/server/api/routers/toolkits/getAuthLink.schema.ts
new file mode 100644
index 0000000000..e58cec7a52
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/toolkits/getAuthLink.schema.ts
@@ -0,0 +1,7 @@
+import { z } from 'zod'
+
+export const getAuthLinkInput = z.object({
+ toolkit: z.string().min(1),
+})
+
+export type GetAuthLinkInput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/toolkits/getAuthLink.ts b/solutions/trustclaw/src/server/api/routers/toolkits/getAuthLink.ts
new file mode 100644
index 0000000000..bc9cbd0623
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/toolkits/getAuthLink.ts
@@ -0,0 +1,35 @@
+import { TRPCError } from '@trpc/server'
+import { protectedProcedure } from '~/server/api/trpc'
+import { createComposioClient } from '~/server/clients/composio'
+import { env } from '~/env'
+import { getAuthLinkInput } from './getAuthLink.schema'
+
+export const getAuthLink = protectedProcedure
+ .input(getAuthLinkInput)
+ .mutation(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id
+ const composio = createComposioClient()
+ const session = await composio.create(userId, {})
+
+ try {
+ const connectionRequest = await session.authorize(input.toolkit, {
+ callbackUrl: `${env.NEXT_PUBLIC_APP_URL}/dashboard/toolkits`,
+ })
+ const redirectUrl = connectionRequest.redirectUrl
+
+ if (!redirectUrl) {
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: 'Failed to generate OAuth URL for this toolkit',
+ })
+ }
+
+ return { redirectUrl }
+ } catch (error) {
+ if (error instanceof TRPCError) throw error
+ throw new TRPCError({
+ code: 'INTERNAL_SERVER_ERROR',
+ message: `Failed to authorize ${input.toolkit}`,
+ })
+ }
+ })
diff --git a/solutions/trustclaw/src/server/api/routers/toolkits/getToolkits.schema.ts b/solutions/trustclaw/src/server/api/routers/toolkits/getToolkits.schema.ts
new file mode 100644
index 0000000000..0c7840b153
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/toolkits/getToolkits.schema.ts
@@ -0,0 +1,20 @@
+import { z } from 'zod'
+
+export const getToolkitsInput = z.object({
+ search: z.string().optional(),
+ isConnected: z.boolean().optional(),
+ cursor: z.string().optional(),
+ limit: z.number().min(1).max(100).default(20),
+})
+
+export type GetToolkitsInput = z.infer
+
+export const toolkitItem = z.object({
+ slug: z.string(),
+ name: z.string(),
+ logo: z.string(),
+ noAuth: z.boolean(),
+ connected: z.boolean(),
+})
+
+export type ToolkitItem = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/toolkits/getToolkits.ts b/solutions/trustclaw/src/server/api/routers/toolkits/getToolkits.ts
new file mode 100644
index 0000000000..2bf157d5d3
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/toolkits/getToolkits.ts
@@ -0,0 +1,41 @@
+import { protectedProcedure } from '~/server/api/trpc'
+import { createComposioClient } from '~/server/clients/composio'
+import { getToolkitsInput } from './getToolkits.schema'
+
+export const getToolkits = protectedProcedure
+ .input(getToolkitsInput)
+ .query(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id
+ const composio = createComposioClient()
+ const session = await composio.create(userId, {})
+
+ // 1. Fetch toolkit listing
+ const toolkitsResult = await session.toolkits({
+ ...(input.search && input.search.length >= 3
+ ? { search: input.search }
+ : {}),
+ ...(input.isConnected !== undefined
+ ? { isConnected: input.isConnected }
+ : {}),
+ limit: input.limit,
+ nextCursor: input.cursor,
+ })
+
+ if (toolkitsResult.items.length === 0) {
+ return { items: [], nextCursor: null }
+ }
+
+ // 2. Merge and return
+ const items = toolkitsResult.items.map((toolkit) => ({
+ slug: toolkit.slug,
+ name: toolkit.name,
+ logo: toolkit.logo ?? `https://logos.composio.dev/api/${toolkit.slug}`,
+ noAuth: toolkit.isNoAuth,
+ connected: !!toolkit.connection?.isActive,
+ }))
+
+ return {
+ items,
+ nextCursor: toolkitsResult.nextCursor ?? null,
+ }
+ })
diff --git a/solutions/trustclaw/src/server/api/routers/toolkits/index.ts b/solutions/trustclaw/src/server/api/routers/toolkits/index.ts
new file mode 100644
index 0000000000..682283d471
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/toolkits/index.ts
@@ -0,0 +1,8 @@
+import { router } from '~/server/api/trpc'
+import { getToolkits } from './getToolkits'
+import { getAuthLink } from './getAuthLink'
+
+export const toolkitsRouter = router({
+ getToolkits,
+ getAuthLink,
+})
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/compaction/memory-flush.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/compaction/memory-flush.ts
new file mode 100644
index 0000000000..c5ca94228e
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/compaction/memory-flush.ts
@@ -0,0 +1,99 @@
+import { generateText, stepCountIs } from 'ai'
+import { db } from '~/server/clients/db'
+import { createCustomTools } from '../tools'
+import { serializeMessages } from './prompts'
+import type { ReconstructedMessage } from '../types'
+
+const FLUSH_SYSTEM_PROMPT =
+ 'Pre-compaction memory flush turn. ' +
+ 'The session is near auto-compaction; capture durable memories now. ' +
+ 'You have access to memory_save and memory_search. ' +
+ 'Save any important context, user preferences, decisions, or ongoing task state that should persist beyond this conversation window. ' +
+ 'If nothing needs saving, respond with .'
+
+const FLUSH_USER_PROMPT =
+ 'Pre-compaction memory flush. ' +
+ 'Store durable memories now using memory_save. ' +
+ 'Focus on: user preferences, key decisions, task progress, important context. ' +
+ 'If nothing to store, reply with .'
+
+interface MemoryFlushParams {
+ instanceId: string
+ anthropicModel: string
+ messages: ReconstructedMessage[]
+ compactionCount: number
+}
+
+interface MemoryFlushResult {
+ memoriesSaved: number
+}
+
+export async function runMemoryFlush(
+ params: MemoryFlushParams
+): Promise {
+ const { instanceId, anthropicModel, messages, compactionCount } = params
+
+ try {
+ const modelString = anthropicModel.startsWith('anthropic/')
+ ? anthropicModel
+ : `anthropic/${anthropicModel}`
+
+ const allCustomTools = createCustomTools(instanceId)
+ const memoryTools = {
+ memory_save: allCustomTools.memory_save,
+ memory_search: allCustomTools.memory_search,
+ }
+
+ const contextSummary = serializeMessages(messages)
+ const flushPrompt = `Here is the recent conversation context:\n\n${contextSummary}\n\n${FLUSH_USER_PROMPT}`
+
+ const result = await generateText({
+ model: modelString,
+ system: FLUSH_SYSTEM_PROMPT,
+ messages: [{ role: 'user' as const, content: flushPrompt }],
+ tools: memoryTools,
+ stopWhen: stepCountIs(3),
+ maxOutputTokens: 1_000,
+ })
+
+ let memoriesSaved = 0
+ for (const step of result.steps) {
+ for (const toolCall of step.toolCalls) {
+ if (toolCall.toolName === 'memory_save') {
+ memoriesSaved++
+ }
+ }
+ }
+
+ await db.$transaction(async (tx) => {
+ await tx.message.create({
+ data: {
+ instanceId,
+ role: 'user',
+ content: [{ type: 'text', text: FLUSH_USER_PROMPT }],
+ source: 'web',
+ messageType: 'memory_flush',
+ },
+ })
+
+ await tx.message.create({
+ data: {
+ instanceId,
+ role: 'assistant',
+ content: [{ type: 'text', text: result.text || '' }],
+ source: 'web',
+ messageType: 'memory_flush',
+ },
+ })
+
+ await tx.composioClawInstance.update({
+ where: { id: instanceId, memoryFlushCount: { lte: compactionCount } },
+ data: { memoryFlushCount: compactionCount + 1 },
+ })
+ })
+
+ return { memoriesSaved }
+ } catch {
+ return { memoriesSaved: 0 }
+ }
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/compaction/prompts.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/compaction/prompts.ts
new file mode 100644
index 0000000000..7bda87f50a
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/compaction/prompts.ts
@@ -0,0 +1,222 @@
+// Adapted from pi-mono: packages/coding-agent/src/core/compaction/compaction.ts:444-514 (summarization prompts)
+// Message serialization from pi-mono: packages/coding-agent/src/core/compaction/utils.ts:93-146
+// Tool failure tracking from openclaw: src/agents/pi-extensions/compaction-safeguard.ts:78-135
+import { z } from 'zod'
+import type { ReconstructedMessage, ToolResultOutput } from '../types'
+
+function outputToString(output: ToolResultOutput): string {
+ if (output.type === 'text') return output.value
+ return JSON.stringify(output.value)
+}
+
+export const COMPACTION_SYSTEM_PROMPT =
+ 'You are a context summarization assistant. Your task is to read a conversation between a user and an AI coding assistant, then produce a structured summary following the exact format specified.\n\nDo NOT continue the conversation. Do NOT respond to any questions in the conversation. ONLY output the structured summary.'
+
+export const INITIAL_SUMMARIZATION_PROMPT = `The messages above are a conversation to summarize. Create a structured context checkpoint summary that another LLM will use to continue the work.
+
+Use this EXACT format:
+
+## Goal
+[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
+
+## Constraints & Preferences
+- [Any constraints, preferences, or requirements mentioned by user]
+- [Or "(none)" if none were mentioned]
+
+## Progress
+### Done
+- [x] [Completed tasks/changes]
+
+### In Progress
+- [ ] [Current work]
+
+### Blocked
+- [Issues preventing progress, if any]
+
+## Key Decisions
+- **[Decision]**: [Brief rationale]
+
+## Next Steps
+1. [Ordered list of what should happen next]
+
+## Critical Context
+- [Any data, examples, or references needed to continue]
+- [Or "(none)" if not applicable]
+
+Keep each section concise. Preserve exact file paths, function names, and error messages.`
+
+export const UPDATE_SUMMARIZATION_PROMPT = `The messages above are NEW conversation messages to incorporate into the existing summary provided in tags.
+
+Update the existing structured summary with new information. RULES:
+- PRESERVE all existing information from the previous summary
+- ADD new progress, decisions, and context from the new messages
+- UPDATE the Progress section: move items from "In Progress" to "Done" when completed
+- UPDATE "Next Steps" based on what was accomplished
+- PRESERVE exact file paths, function names, and error messages
+- If something is no longer relevant, you may remove it
+
+Use this EXACT format:
+
+## Goal
+[What is the user trying to accomplish? Can be multiple items if the session covers different tasks.]
+
+## Constraints & Preferences
+- [Any constraints, preferences, or requirements mentioned by user]
+- [Or "(none)" if none were mentioned]
+
+## Progress
+### Done
+- [x] [Completed tasks/changes]
+
+### In Progress
+- [ ] [Current work]
+
+### Blocked
+- [Issues preventing progress, if any]
+
+## Key Decisions
+- **[Decision]**: [Brief rationale]
+
+## Next Steps
+1. [Ordered list of what should happen next]
+
+## Critical Context
+- [Any data, examples, or references needed to continue]
+- [Or "(none)" if not applicable]
+
+Keep each section concise. Preserve exact file paths, function names, and error messages.`
+
+export const MERGE_SUMMARIES_PROMPT =
+ 'Merge these partial summaries into a single cohesive summary. Preserve decisions, TODOs, open questions, and any constraints.'
+
+export const COMPACTION_SUMMARY_PREFIX =
+ 'The conversation history before this point was compacted into the following summary:'
+
+export function serializeMessages(messages: ReconstructedMessage[]): string {
+ const lines: string[] = []
+
+ for (const msg of messages) {
+ if (msg.role === 'user') {
+ lines.push(`[User]: ${msg.content}`)
+ continue
+ }
+
+ if (msg.role === 'assistant') {
+ if (typeof msg.content === 'string') {
+ lines.push(`[Assistant]: ${msg.content}`)
+ continue
+ }
+
+ const textParts: string[] = []
+ const toolCalls: string[] = []
+
+ for (const part of msg.content) {
+ if (part.type === 'text') {
+ textParts.push(part.text)
+ } else {
+ const args = formatToolArgs(part.input)
+ toolCalls.push(`${part.toolName}(${args})`)
+ }
+ }
+
+ if (textParts.length > 0) {
+ lines.push(`[Assistant]: ${textParts.join('\n')}`)
+ }
+ if (toolCalls.length > 0) {
+ lines.push(`[Assistant tool calls]: ${toolCalls.join('; ')}`)
+ }
+ continue
+ }
+
+ for (const part of msg.content) {
+ const outputStr = outputToString(part.output)
+ lines.push(`[Tool result for ${part.toolName}]: ${outputStr}`)
+ }
+ }
+
+ return lines.join('\n')
+}
+
+const plainRecordSchema = z.record(z.unknown())
+
+function formatToolArgs(input: unknown): string {
+ if (input === null || input === undefined) return ''
+ if (typeof input !== 'object') return JSON.stringify(input) ?? ''
+
+ const parsed = plainRecordSchema.safeParse(input)
+ if (!parsed.success) return ''
+ return Object.entries(parsed.data)
+ .map(([k, v]) => {
+ const val = typeof v === 'string' ? v : JSON.stringify(v)
+ const truncated = val.length > 100 ? val.slice(0, 100) + '...' : val
+ return `${k}=${truncated}`
+ })
+ .join(', ')
+}
+
+export function buildToolFailuresSuffix(
+ messages: ReconstructedMessage[]
+): string {
+ const failures: string[] = []
+ const MAX_FAILURES = 8
+
+ for (const msg of messages) {
+ if (msg.role !== 'tool') continue
+
+ for (const part of msg.content) {
+ const outputStr = outputToString(part.output)
+
+ const isError =
+ outputStr.includes('error') ||
+ outputStr.includes('Error') ||
+ outputStr.includes('exitCode') ||
+ outputStr.includes('failed')
+
+ if (!isError) continue
+
+ const exitCodeMatch = /exitCode[=:]?\s*(\d+)/.exec(outputStr)
+ const exitCode = exitCodeMatch?.[1]
+ const truncatedOutput =
+ outputStr.length > 200 ? outputStr.slice(0, 200) + '...' : outputStr
+
+ if (exitCode && exitCode !== '0') {
+ failures.push(
+ `- ${part.toolName} (exitCode=${exitCode}): ${truncatedOutput}`
+ )
+ } else {
+ failures.push(`- ${part.toolName}: ${truncatedOutput}`)
+ }
+
+ if (failures.length >= MAX_FAILURES) break
+ }
+ if (failures.length >= MAX_FAILURES) break
+ }
+
+ if (failures.length === 0) return ''
+
+ const totalErrors = countToolErrors(messages)
+ let suffix = `\n\n## Tool Failures\n${failures.join('\n')}`
+ if (totalErrors > MAX_FAILURES) {
+ suffix += `\n...and ${totalErrors - MAX_FAILURES} more`
+ }
+ return suffix
+}
+
+function countToolErrors(messages: ReconstructedMessage[]): number {
+ let count = 0
+ for (const msg of messages) {
+ if (msg.role !== 'tool') continue
+ for (const part of msg.content) {
+ const outputStr = outputToString(part.output)
+ if (
+ outputStr.includes('error') ||
+ outputStr.includes('Error') ||
+ outputStr.includes('exitCode') ||
+ outputStr.includes('failed')
+ ) {
+ count++
+ }
+ }
+ }
+ return count
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/compaction/run-compaction.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/compaction/run-compaction.ts
new file mode 100644
index 0000000000..1e0bbc613e
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/compaction/run-compaction.ts
@@ -0,0 +1,236 @@
+// Adapted from pi-mono: packages/coding-agent/src/core/compaction/compaction.ts:376-438 (cut point algorithm)
+// Adaptive chunking / staged summarization from openclaw: src/agents/compaction.ts:110-129, 244-305
+// Fallback chain from openclaw: src/agents/compaction.ts:176-242
+import { generateText } from 'ai'
+import { db } from '~/server/clients/db'
+import type { ReconstructedMessage } from '../types'
+import { estimateMessageTokens } from '../context/token-estimation'
+import {
+ COMPACTION_SYSTEM_PROMPT,
+ INITIAL_SUMMARIZATION_PROMPT,
+ UPDATE_SUMMARIZATION_PROMPT,
+ MERGE_SUMMARIES_PROMPT,
+ serializeMessages,
+ buildToolFailuresSuffix,
+} from './prompts'
+import { sanitizeString } from '../context/build-context'
+
+interface CompactionParams {
+ instanceId: string
+ anthropicModel: string
+ messages: ReconstructedMessage[]
+ keepRecentTokens: number
+ previousSummary: string | null
+ compactionCount: number
+}
+
+interface CompactionResult {
+ summary: string
+ keptMessageCount: number
+ compactedMessageCount: number
+}
+
+const ADAPTIVE_CHUNK_THRESHOLD = 100_000
+const LARGE_TOOL_RESULT_THRESHOLD = 10_000
+
+export function findCutPoint(
+ messages: ReconstructedMessage[],
+ keepRecentTokens: number
+): number {
+ if (messages.length <= 2) return 0
+
+ let accumulatedTokens = 0
+ let foundCut = false
+ let rawCutIndex = 0
+
+ for (let i = messages.length - 1; i >= 0; i--) {
+ accumulatedTokens += estimateMessageTokens(messages[i]!)
+ if (accumulatedTokens >= keepRecentTokens) {
+ rawCutIndex = i
+ foundCut = true
+ break
+ }
+ }
+
+ if (!foundCut) return 0
+
+ for (let i = rawCutIndex; i < messages.length; i++) {
+ const msg = messages[i]!
+ if (msg.role === 'user' || msg.role === 'assistant') {
+ return i
+ }
+ }
+
+ return 0
+}
+
+async function summarize(
+ anthropicModel: string,
+ conversationText: string,
+ previousSummary: string | null
+): Promise {
+ const modelString = anthropicModel.startsWith('anthropic/')
+ ? anthropicModel
+ : `anthropic/${anthropicModel}`
+
+ const safeConversation = sanitizeString(conversationText)
+ const safePreviousSummary = previousSummary
+ ? sanitizeString(previousSummary)
+ : null
+
+ let prompt: string
+ if (safePreviousSummary) {
+ prompt = `\n${safeConversation}\n\n\n\n${safePreviousSummary}\n\n\n${UPDATE_SUMMARIZATION_PROMPT}`
+ } else {
+ prompt = `\n${safeConversation}\n\n\n${INITIAL_SUMMARIZATION_PROMPT}`
+ }
+
+ const result = await generateText({
+ model: modelString,
+ system: COMPACTION_SYSTEM_PROMPT,
+ messages: [{ role: 'user', content: prompt }],
+ maxOutputTokens: 4_000,
+ })
+
+ return result.text
+}
+
+async function stagedSummarize(
+ anthropicModel: string,
+ messages: ReconstructedMessage[],
+ previousSummary: string | null
+): Promise {
+ const midpoint = Math.floor(messages.length / 2)
+ const firstHalf = messages.slice(0, midpoint)
+ const secondHalf = messages.slice(midpoint)
+
+ const firstText = serializeMessages(firstHalf)
+ const secondText = serializeMessages(secondHalf)
+
+ const firstSummary = await summarize(
+ anthropicModel,
+ firstText,
+ previousSummary
+ )
+
+ const secondSummary = await summarize(
+ anthropicModel,
+ secondText,
+ firstSummary
+ )
+
+ const mergeModelString = anthropicModel.startsWith('anthropic/')
+ ? anthropicModel
+ : `anthropic/${anthropicModel}`
+ const mergeResult = await generateText({
+ model: mergeModelString,
+ system: COMPACTION_SYSTEM_PROMPT,
+ messages: [
+ {
+ role: 'user',
+ content: `\n${firstSummary}\n\n\n\n${secondSummary}\n\n\n${MERGE_SUMMARIES_PROMPT}`,
+ },
+ ],
+ maxOutputTokens: 4_000,
+ })
+
+ return mergeResult.text
+}
+
+function stripLargeToolResults(
+ messages: ReconstructedMessage[]
+): ReconstructedMessage[] {
+ return messages.map((msg) => {
+ if (msg.role !== 'tool') return msg
+ return {
+ ...msg,
+ content: msg.content.map((part) => {
+ const outputStr = JSON.stringify(part.output)
+ if (outputStr.length > LARGE_TOOL_RESULT_THRESHOLD) {
+ return {
+ ...part,
+ output: {
+ type: 'text' as const,
+ value: '[Large tool result omitted]',
+ },
+ }
+ }
+ return part
+ }),
+ }
+ })
+}
+
+export async function runCompaction(
+ params: CompactionParams
+): Promise {
+ const {
+ instanceId,
+ anthropicModel,
+ messages,
+ keepRecentTokens,
+ previousSummary,
+ compactionCount,
+ } = params
+
+ const cutIndex = findCutPoint(messages, keepRecentTokens)
+ if (cutIndex <= 0) return null
+
+ const messagesToCompact = messages.slice(0, cutIndex)
+ const keptMessageCount = messages.length - cutIndex
+
+ let summary: string
+
+ try {
+ const conversationText = serializeMessages(messagesToCompact)
+
+ if (conversationText.length > ADAPTIVE_CHUNK_THRESHOLD) {
+ summary = await stagedSummarize(
+ anthropicModel,
+ messagesToCompact,
+ previousSummary
+ )
+ } else {
+ summary = await summarize(
+ anthropicModel,
+ conversationText,
+ previousSummary
+ )
+ }
+ } catch {
+ try {
+ const stripped = stripLargeToolResults(messagesToCompact)
+ const strippedText = serializeMessages(stripped)
+ summary = await summarize(anthropicModel, strippedText, previousSummary)
+ } catch {
+ summary = `Conversation covered ${messagesToCompact.length} messages. Summary unavailable due to context limits.`
+ }
+ }
+
+ const failuresSuffix = buildToolFailuresSuffix(messagesToCompact)
+ if (failuresSuffix) {
+ summary += failuresSuffix
+ }
+
+ const estimatedTokens = Math.ceil(summary.length / 4)
+
+ try {
+ await db.composioClawInstance.update({
+ where: { id: instanceId, compactionCount },
+ data: {
+ lastCompactionSummary: summary,
+ compactionCount: { increment: 1 },
+ lastCompactionAt: new Date(),
+ tokensAtCompaction: estimatedTokens,
+ },
+ })
+ } catch {
+ return null
+ }
+
+ return {
+ summary,
+ keptMessageCount,
+ compactedMessageCount: cutIndex,
+ }
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/context/build-context.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/context/build-context.ts
new file mode 100644
index 0000000000..cc80fb7a5c
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/context/build-context.ts
@@ -0,0 +1,284 @@
+import { z } from 'zod'
+import { db } from '~/server/clients/db'
+import type { Prisma } from '~/generated/prisma/client'
+import type {
+ ReconstructedMessage,
+ JsonValue,
+ ToolResultOutput,
+} from '../types'
+import {
+ shouldCompact,
+ shouldFlushMemory,
+ type CompactionSettings,
+} from './token-estimation'
+import { runCompaction } from '../compaction/run-compaction'
+import { runMemoryFlush } from '../compaction/memory-flush'
+import { COMPACTION_SUMMARY_PREFIX } from '../compaction/prompts'
+
+const MESSAGE_SAFETY_CAP = 200
+
+// Lone surrogates in strings produce invalid JSON when serialized for the Anthropic API.
+// This can happen when external tool results (e.g. from Composio) contain malformed Unicode.
+const LONE_SURROGATE_RE =
+ /[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?(value: T): T {
+ if (typeof value === 'string') {
+ return sanitizeString(value) as T
+ }
+ if (Array.isArray(value)) {
+ return value.map(deepSanitize) as T
+ }
+ if (value !== null && typeof value === 'object') {
+ const out: Record = {}
+ for (const [k, v] of Object.entries(value)) {
+ out[k] = deepSanitize(v)
+ }
+ return out as T
+ }
+ return value
+}
+
+export const contentPartSchema = z.object({
+ type: z.string(),
+ text: z.string().optional(),
+})
+
+export const contentSchema = z.array(contentPartSchema)
+
+export const plainRecordSchema = z.record(z.unknown())
+
+export const jsonValueSchema: z.ZodType = z.lazy(() =>
+ z.union([
+ z.null(),
+ z.string(),
+ z.number(),
+ z.boolean(),
+ z.array(jsonValueSchema),
+ z.record(jsonValueSchema),
+ ])
+)
+
+export { sanitizeString, deepSanitize }
+
+export function toJsonValue(value: unknown): JsonValue {
+ const parsed = jsonValueSchema.safeParse(value ?? {})
+ return parsed.success ? deepSanitize(parsed.data) : {}
+}
+
+export function toToolResultOutput(value: unknown): ToolResultOutput {
+ return { type: 'json', value: toJsonValue(value) }
+}
+
+export function toPlainRecord(value: unknown): Record {
+ const raw: unknown = JSON.parse(JSON.stringify(value ?? {}))
+ return plainRecordSchema.parse(raw)
+}
+
+export function toPlainRecordSafe(value: unknown): Record {
+ const result = plainRecordSchema.safeParse(
+ JSON.parse(JSON.stringify(value ?? {}))
+ )
+ if (!result.success) {
+ console.error(
+ '[toPlainRecordSafe] Non-record tool input fell back to {}:',
+ typeof value
+ )
+ }
+ return result.success ? result.data : {}
+}
+
+export function toPrismaJson(value: unknown): Prisma.InputJsonValue {
+ return toJsonValue(
+ JSON.parse(JSON.stringify(value ?? {}))
+ ) satisfies JsonValue as Prisma.InputJsonValue
+}
+
+export async function loadContextMessages(
+ instanceId: string,
+ lastCompactionAt: Date | null
+) {
+ return db.message.findMany({
+ where: {
+ instanceId,
+ messageType: 'regular',
+ ...(lastCompactionAt ? { createdAt: { gte: lastCompactionAt } } : {}),
+ },
+ orderBy: { createdAt: 'asc' },
+ take: MESSAGE_SAFETY_CAP,
+ select: {
+ role: true,
+ content: true,
+ },
+ })
+}
+
+export function buildContext(
+ dbMessages: Awaited>,
+ lastCompactionSummary: string | null,
+ userMessage: string
+): ReconstructedMessage[] {
+ const aiMessages = deepSanitize(reconstructMessages(dbMessages))
+
+ if (lastCompactionSummary) {
+ aiMessages.unshift({
+ role: 'user' as const,
+ content: sanitizeString(
+ `${COMPACTION_SUMMARY_PREFIX}\n\n\n${lastCompactionSummary}\n`
+ ),
+ })
+ }
+
+ aiMessages.push({
+ role: 'user' as const,
+ content: sanitizeString(userMessage),
+ })
+
+ return aiMessages
+}
+
+const dynamicToolPartSchema = z.object({
+ type: z.literal('dynamic-tool'),
+ toolCallId: z.string(),
+ toolName: z.string(),
+ state: z.string(),
+ input: z.unknown().optional(),
+ output: z.unknown().optional(),
+})
+
+export function reconstructMessages(
+ messages: Array<{
+ role: string
+ content: unknown
+ }>
+): ReconstructedMessage[] {
+ const result: ReconstructedMessage[] = []
+
+ for (const msg of messages) {
+ const role = msg.role === 'assistant' ? 'assistant' : 'user'
+
+ const contentArray = Array.isArray(msg.content) ? msg.content : []
+ const parsed = contentSchema.safeParse(contentArray)
+ const contentParts = parsed.success ? parsed.data : []
+
+ const textContent = contentParts
+ .filter((p) => p.type === 'text' && p.text)
+ .map((p) => p.text!)
+ .join('\n')
+
+ if (role === 'user') {
+ result.push({ role: 'user', content: textContent || '(empty)' })
+ continue
+ }
+
+ // Extract dynamic-tool parts from content JSON
+ const toolParts = contentArray
+ .map((item: unknown) => dynamicToolPartSchema.safeParse(item))
+ .filter(
+ (r): r is z.SafeParseSuccess> =>
+ r.success
+ )
+ .map((r) => r.data)
+
+ if (toolParts.length === 0) {
+ result.push({ role: 'assistant', content: textContent || '(empty)' })
+ continue
+ }
+
+ const assistantContent: Array<
+ | { type: 'text'; text: string }
+ | {
+ type: 'tool-call'
+ toolCallId: string
+ toolName: string
+ input: Record
+ }
+ > = []
+ if (textContent) {
+ assistantContent.push({ type: 'text', text: textContent })
+ }
+ for (const tc of toolParts) {
+ assistantContent.push({
+ type: 'tool-call',
+ toolCallId: tc.toolCallId,
+ toolName: tc.toolName,
+ input: toPlainRecordSafe(tc.input),
+ })
+ }
+ result.push({ role: 'assistant', content: assistantContent })
+
+ result.push({
+ role: 'tool',
+ content: toolParts.map((tc) => ({
+ type: 'tool-result' as const,
+ toolCallId: tc.toolCallId,
+ toolName: tc.toolName,
+ output: toToolResultOutput(tc.output),
+ })),
+ })
+ }
+
+ return result
+}
+
+export async function runPostResponseTasks(params: {
+ instanceId: string
+ instance: {
+ anthropicModel: string
+ compactionCount: number
+ memoryFlushCount: number
+ lastCompactionSummary: string | null
+ lastCompactionAt: Date | null
+ }
+ contextTokens: number
+ settings: CompactionSettings
+ prunedMessages: ReconstructedMessage[]
+}): Promise {
+ const { instanceId, instance, contextTokens, settings, prunedMessages } =
+ params
+
+ if (
+ shouldFlushMemory(
+ contextTokens,
+ settings,
+ instance.compactionCount,
+ instance.memoryFlushCount
+ )
+ ) {
+ try {
+ await runMemoryFlush({
+ instanceId,
+ anthropicModel: instance.anthropicModel,
+ messages: prunedMessages,
+ compactionCount: instance.compactionCount,
+ })
+ } catch {
+ // Flush failure is non-fatal
+ }
+ }
+
+ if (shouldCompact(contextTokens, settings)) {
+ try {
+ const freshDbMessages = await loadContextMessages(
+ instanceId,
+ instance.lastCompactionAt
+ )
+ const freshAiMessages = reconstructMessages(freshDbMessages)
+
+ await runCompaction({
+ instanceId,
+ anthropicModel: instance.anthropicModel,
+ messages: freshAiMessages,
+ keepRecentTokens: settings.keepRecentTokens,
+ previousSummary: instance.lastCompactionSummary,
+ compactionCount: instance.compactionCount,
+ })
+ } catch {
+ // Compaction failure is non-fatal - next turn will retry
+ }
+ }
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/context/context-pruning.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/context/context-pruning.ts
new file mode 100644
index 0000000000..9f1ed159b3
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/context/context-pruning.ts
@@ -0,0 +1,150 @@
+// Adapted from openclaw: src/agents/pi-extensions/context-pruning/pruner.ts:225-346
+// Settings from openclaw: src/agents/pi-extensions/context-pruning/settings.ts:48-65
+import type { ReconstructedMessage } from '../types'
+import { sanitizeString } from './build-context'
+
+const SOFT_TRIM_RATIO = 0.3
+const HARD_CLEAR_RATIO = 0.5
+const KEEP_LAST_ASSISTANTS = 3
+const SOFT_TRIM_MAX_CHARS = 4_000
+const SOFT_TRIM_HEAD_CHARS = 1_500
+const SOFT_TRIM_TAIL_CHARS = 1_500
+const MIN_PRUNABLE_TOOL_CHARS = 50_000
+const HARD_CLEAR_PLACEHOLDER = '[Old tool result content cleared]'
+const CHARS_PER_TOKEN_ESTIMATE = 4
+
+interface PruneResult {
+ messages: ReconstructedMessage[]
+ prunedCount: number
+}
+
+function estimateMessageChars(msg: ReconstructedMessage): number {
+ if (msg.role === 'user') {
+ return msg.content.length
+ }
+ if (msg.role === 'assistant') {
+ if (typeof msg.content === 'string') {
+ return msg.content.length
+ }
+ let chars = 0
+ for (const part of msg.content) {
+ if (part.type === 'text') {
+ chars += part.text.length
+ } else {
+ chars += JSON.stringify(part.input).length + part.toolName.length
+ }
+ }
+ return chars
+ }
+ // tool message
+ let chars = 0
+ for (const part of msg.content) {
+ chars += JSON.stringify(part.output).length + part.toolName.length
+ }
+ return chars
+}
+
+function deepCloneMessages(
+ messages: ReconstructedMessage[]
+): ReconstructedMessage[] {
+ return structuredClone(messages)
+}
+
+function findProtectedBoundary(messages: ReconstructedMessage[]): number {
+ let assistantCount = 0
+ for (let i = messages.length - 1; i >= 0; i--) {
+ if (messages[i]!.role === 'assistant') {
+ assistantCount++
+ if (assistantCount >= KEEP_LAST_ASSISTANTS) {
+ return i
+ }
+ }
+ }
+ return 0
+}
+
+export function pruneContext(
+ messages: ReconstructedMessage[],
+ contextWindow: number
+): PruneResult {
+ const charWindow = contextWindow * CHARS_PER_TOKEN_ESTIMATE
+ let totalChars = 0
+ for (const msg of messages) {
+ totalChars += estimateMessageChars(msg)
+ }
+
+ const ratio = totalChars / charWindow
+
+ if (ratio < SOFT_TRIM_RATIO) {
+ return { messages, prunedCount: 0 }
+ }
+
+ const result = deepCloneMessages(messages)
+ let prunedCount = 0
+
+ const protectedBoundary = findProtectedBoundary(result)
+
+ if (ratio >= SOFT_TRIM_RATIO) {
+ for (let i = 0; i < protectedBoundary; i++) {
+ const msg = result[i]!
+ if (msg.role !== 'tool') continue
+
+ for (let j = 0; j < msg.content.length; j++) {
+ const part = msg.content[j]!
+ const outputStr = JSON.stringify(part.output)
+
+ if (outputStr.length > SOFT_TRIM_MAX_CHARS) {
+ const head = outputStr.slice(0, SOFT_TRIM_HEAD_CHARS)
+ const tail = outputStr.slice(-SOFT_TRIM_TAIL_CHARS)
+ const trimmedChars =
+ outputStr.length - SOFT_TRIM_HEAD_CHARS - SOFT_TRIM_TAIL_CHARS
+
+ msg.content[j] = {
+ ...part,
+ output: {
+ type: 'text' as const,
+ value: sanitizeString(
+ `${head}\n...[trimmed ${trimmedChars} chars]...\n${tail}`
+ ),
+ },
+ }
+ prunedCount++
+ }
+ }
+ }
+
+ totalChars = 0
+ for (const msg of result) {
+ totalChars += estimateMessageChars(msg)
+ }
+ }
+
+ if (totalChars / charWindow >= HARD_CLEAR_RATIO) {
+ for (let i = 0; i < protectedBoundary; i++) {
+ if (totalChars / charWindow < HARD_CLEAR_RATIO) break
+
+ const msg = result[i]!
+ if (msg.role !== 'tool') continue
+
+ let toolChars = 0
+ for (const part of msg.content) {
+ toolChars += JSON.stringify(part.output).length
+ }
+
+ if (toolChars < MIN_PRUNABLE_TOOL_CHARS) continue
+
+ const charsBefore = estimateMessageChars(msg)
+ for (let j = 0; j < msg.content.length; j++) {
+ msg.content[j] = {
+ ...msg.content[j]!,
+ output: { type: 'text' as const, value: HARD_CLEAR_PLACEHOLDER },
+ }
+ }
+ const charsAfter = estimateMessageChars(msg)
+ totalChars -= charsBefore - charsAfter
+ prunedCount++
+ }
+ }
+
+ return { messages: result, prunedCount }
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/context/context-window.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/context/context-window.ts
new file mode 100644
index 0000000000..9a02f81471
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/context/context-window.ts
@@ -0,0 +1,5 @@
+const CONTEXT_WINDOW = 200_000
+
+export function getContextWindow(_modelId: string): number {
+ return CONTEXT_WINDOW
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/context/token-estimation.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/context/token-estimation.ts
new file mode 100644
index 0000000000..422ed035e7
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/context/token-estimation.ts
@@ -0,0 +1,93 @@
+// Token estimation heuristic (chars/4) from pi-mono: packages/coding-agent/src/core/compaction/compaction.ts:225-283
+import type { ReconstructedMessage } from '../types'
+
+export interface CompactionSettings {
+ contextWindow: number
+ reserveTokens: number
+ keepRecentTokens: number
+}
+
+export const DEFAULT_COMPACTION_SETTINGS: Omit<
+ CompactionSettings,
+ 'contextWindow'
+> = {
+ reserveTokens: 20_000,
+ keepRecentTokens: 20_000,
+}
+
+export function estimateMessageTokens(msg: ReconstructedMessage): number {
+ if (msg.role === 'user') {
+ return Math.ceil(msg.content.length / 4)
+ }
+
+ if (msg.role === 'assistant') {
+ if (typeof msg.content === 'string') {
+ return Math.ceil(msg.content.length / 4)
+ }
+ let chars = 0
+ for (const part of msg.content) {
+ if (part.type === 'text') {
+ chars += part.text.length
+ } else {
+ chars += JSON.stringify(part.input).length
+ chars += part.toolName.length
+ }
+ }
+ return Math.ceil(chars / 4)
+ }
+
+ let chars = 0
+ for (const part of msg.content) {
+ chars += JSON.stringify(part.output).length
+ chars += part.toolName.length
+ }
+ return Math.ceil(chars / 4)
+}
+
+export function calculateContextTokens(usage: {
+ inputTokens: number
+ outputTokens: number
+ totalTokens: number
+}): number {
+ return usage.totalTokens || usage.inputTokens + usage.outputTokens
+}
+
+export function estimateContextTokens(
+ messages: ReconstructedMessage[],
+ systemPromptTokens: number,
+ lastUsage?: {
+ inputTokens: number
+ outputTokens: number
+ totalTokens: number
+ }
+): number {
+ if (lastUsage) {
+ return calculateContextTokens(lastUsage)
+ }
+
+ let total = systemPromptTokens
+ for (const msg of messages) {
+ total += estimateMessageTokens(msg)
+ }
+ return total
+}
+
+export function shouldCompact(
+ contextTokens: number,
+ settings: CompactionSettings
+): boolean {
+ return contextTokens > settings.contextWindow - settings.reserveTokens
+}
+
+export const FLUSH_SOFT_TOKENS = 4_000
+
+export function shouldFlushMemory(
+ contextTokens: number,
+ settings: CompactionSettings,
+ compactionCount: number,
+ memoryFlushCount: number
+): boolean {
+ const flushThreshold =
+ settings.contextWindow - settings.reserveTokens - FLUSH_SOFT_TOKENS
+ return contextTokens >= flushThreshold && memoryFlushCount <= compactionCount
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/error-parser.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/error-parser.ts
new file mode 100644
index 0000000000..5c6b34d4e0
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/error-parser.ts
@@ -0,0 +1,44 @@
+import { z } from 'zod'
+
+const composioApiErrorSchema = z
+ .object({
+ error: z
+ .object({
+ message: z.string().optional(),
+ suggested_fix: z.string().optional(),
+ })
+ .optional(),
+ })
+ .passthrough()
+
+export function parseAgentError(error: unknown): string {
+ const raw = error instanceof Error ? error.message : String(error)
+
+ const jsonMatch = /\d{3}\s*(\{.*\})/.exec(raw)
+ if (jsonMatch?.[1]) {
+ try {
+ const rawJson: unknown = JSON.parse(jsonMatch[1])
+ const parsed = composioApiErrorSchema.safeParse(rawJson)
+ if (parsed.success) {
+ if (parsed.data.error?.suggested_fix) {
+ return parsed.data.error.suggested_fix
+ }
+ if (parsed.data.error?.message) {
+ return parsed.data.error.message
+ }
+ }
+ } catch {
+ // Fall through
+ }
+ }
+
+ if (raw.includes('invalid x-api-key') || raw.includes('invalid_api_key')) {
+ return 'Invalid Anthropic API key. Please check the server configuration.'
+ }
+
+ if (raw.includes('rate_limit') || raw.includes('429')) {
+ return 'Rate limit exceeded. Please wait a moment and try again.'
+ }
+
+ return 'Something went wrong. Please try again.'
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/index.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/index.ts
new file mode 100644
index 0000000000..8035a1bb6f
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/index.ts
@@ -0,0 +1,8 @@
+export { prepareAgentRun } from './setup'
+export type {
+ PrepareAgentRunParams,
+ PrepareResult,
+ PrepareAgentRunResult,
+ MessageSource,
+} from './setup'
+export { computeNextRunAt, validateCronExpression } from './tools/cron-utils'
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/setup.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/setup.ts
new file mode 100644
index 0000000000..28140b3535
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/setup.ts
@@ -0,0 +1,277 @@
+import { ToolLoopAgent, stepCountIs } from 'ai'
+import type { ToolSet, SystemModelMessage } from 'ai'
+import { db } from '~/server/clients/db'
+import { createComposioClient } from '~/server/clients/composio'
+import { buildSystemPrompt } from './system-prompt'
+import { createCustomTools, searchMemoriesForContext } from './tools'
+import { getContextWindow } from './context/context-window'
+import { pruneContext } from './context/context-pruning'
+import {
+ loadContextMessages,
+ buildContext,
+ toPlainRecordSafe,
+ toPrismaJson,
+ runPostResponseTasks,
+ sanitizeString,
+ deepSanitize,
+} from './context/build-context'
+import {
+ DEFAULT_COMPACTION_SETTINGS,
+ type CompactionSettings,
+} from './context/token-estimation'
+import { stripToolResultEchoes } from './strip-tool-echoes'
+import { clearStreamingMessage } from '~/server/clients/redis'
+import type { ReconstructedMessage } from './types'
+
+type MessageSource = 'web' | 'telegram' | 'cron'
+
+/**
+ * Wraps every tool's execute function to sanitize its return value,
+ * replacing lone Unicode surrogates with U+FFFD. Composio tool results
+ * (e.g. scraped web pages, email bodies) can contain malformed Unicode
+ * that produces invalid JSON when the AI SDK serializes the request
+ * body for the Anthropic API.
+ */
+function sanitizeToolResults(tools: ToolSet): ToolSet {
+ const wrapped: ToolSet = {}
+ for (const [name, tool] of Object.entries(tools)) {
+ if (tool.execute) {
+ const originalExecute = tool.execute
+ wrapped[name] = {
+ ...tool,
+ execute: async (...args: Parameters) => {
+ const result = await originalExecute(...args)
+ return deepSanitize(result)
+ },
+ }
+ } else {
+ wrapped[name] = tool
+ }
+ }
+ return wrapped
+}
+
+interface PrepareAgentRunParams {
+ instanceId: string
+ userMessage: string
+ source: MessageSource
+ userMessageType?: 'hidden'
+}
+
+interface PrepareAgentRunResult {
+ agent: ToolLoopAgent
+ messages: ReconstructedMessage[]
+}
+
+type PrepareResult = { status: 'ready'; result: PrepareAgentRunResult }
+
+export async function prepareAgentRun(
+ params: PrepareAgentRunParams
+): Promise {
+ const { instanceId, userMessage, source, userMessageType } = params
+
+ const instance = await db.composioClawInstance.findUnique({
+ where: { id: instanceId },
+ })
+
+ if (!instance) {
+ throw new Error('Instance not found')
+ }
+
+ const user = await db.user.findUnique({
+ where: { id: instance.userId },
+ select: { timezone: true },
+ })
+
+ const userTimezone = user?.timezone ?? 'UTC'
+
+ const relevantMemories = await searchMemoriesForContext(
+ instanceId,
+ userMessage
+ )
+
+ const systemPrompt = sanitizeString(
+ buildSystemPrompt({
+ soulPrompt: instance.soulPrompt,
+ identityPrompt: instance.identityPrompt,
+ userPrompt: instance.userPrompt,
+ relevantMemories,
+ hasCompactionSummary: !!instance.lastCompactionSummary,
+ userTimezone,
+ })
+ )
+
+ const dbMessages = await loadContextMessages(
+ instanceId,
+ instance.lastCompactionAt
+ )
+ const aiMessages = buildContext(
+ dbMessages,
+ instance.lastCompactionSummary,
+ userMessage
+ )
+
+ const contextWindow = getContextWindow(instance.anthropicModel)
+ const { messages: prunedMessages } = pruneContext(aiMessages, contextWindow)
+
+ // Add cache breakpoint to last history message (before new user message)
+ // so the conversation prefix is cached across turns
+ if (prunedMessages.length >= 2) {
+ const lastHistoryIndex = prunedMessages.length - 2
+ const msg = prunedMessages[lastHistoryIndex]!
+ prunedMessages[lastHistoryIndex] = {
+ ...msg,
+ providerOptions: {
+ anthropic: { cacheControl: { type: 'ephemeral' } },
+ },
+ }
+ }
+
+ await db.message.create({
+ data: {
+ instanceId,
+ role: 'user',
+ content: [{ type: 'text', text: userMessage }],
+ source,
+ ...(userMessageType && { messageType: userMessageType }),
+ },
+ })
+
+ const composio = createComposioClient()
+ const session = await composio.create(instance.userId, {
+ manageConnections: {
+ waitForConnections: true,
+ },
+ })
+ const composioTools = await session.tools()
+
+ const customTools = createCustomTools(instanceId, userTimezone)
+
+ const allTools: ToolSet = sanitizeToolResults({
+ ...composioTools,
+ ...customTools,
+ })
+
+ // Pre-create assistant message row so we can update it in onFinish
+ const assistantMessageRow = await db.message.create({
+ data: {
+ instanceId,
+ role: 'assistant',
+ content: toPrismaJson([]),
+ source,
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ },
+ })
+
+ const modelString = instance.anthropicModel.startsWith('anthropic/')
+ ? instance.anthropicModel
+ : `anthropic/${instance.anthropicModel}`
+ const model = modelString
+
+ const agent = new ToolLoopAgent({
+ model,
+ instructions: {
+ role: 'system',
+ content: systemPrompt,
+ providerOptions: {
+ anthropic: { cacheControl: { type: 'ephemeral' } },
+ },
+ } satisfies SystemModelMessage,
+ tools: allTools,
+ stopWhen: stepCountIs(100),
+ onFinish: async (result) => {
+ try {
+ const { totalUsage, steps } = result
+ const inputTokens = totalUsage.inputTokens ?? 0
+ const outputTokens = totalUsage.outputTokens ?? 0
+ const cacheReadTokens =
+ totalUsage.inputTokenDetails?.cacheReadTokens ?? 0
+ const cacheWriteTokens =
+ totalUsage.inputTokenDetails?.cacheWriteTokens ?? 0
+
+ // Build assistant content from steps (UIMessage parts format)
+ const assistantParts: Array> = []
+
+ for (const step of steps) {
+ for (let i = 0; i < step.toolCalls.length; i++) {
+ const tc = step.toolCalls[i]!
+ const tr = step.toolResults[i]
+ const tcInput = toPlainRecordSafe(tc.input)
+ const tcResult = tr ? toPlainRecordSafe(tr.output) : null
+
+ assistantParts.push({
+ type: 'dynamic-tool' as const,
+ toolCallId: tc.toolCallId,
+ toolName: tc.toolName,
+ state: tcResult ? 'output-available' : 'input-available',
+ input: tcInput,
+ output: tcResult ?? {},
+ })
+ }
+
+ const stepText = stripToolResultEchoes(step.text)
+ if (stepText) {
+ assistantParts.push({ type: 'text' as const, text: stepText })
+ }
+ }
+
+ // Update the pre-created assistant message with final content + totals
+ await db.message.update({
+ where: { id: assistantMessageRow.id },
+ data: {
+ content: toPrismaJson(assistantParts),
+ inputTokens,
+ outputTokens,
+ cacheReadTokens,
+ cacheWriteTokens,
+ },
+ })
+
+ // Fire-and-forget post-response tasks
+ const totalContextTokens = inputTokens + outputTokens
+ const settings: CompactionSettings = {
+ contextWindow,
+ ...DEFAULT_COMPACTION_SETTINGS,
+ }
+
+ void runPostResponseTasks({
+ instanceId,
+ instance: {
+ anthropicModel: instance.anthropicModel,
+ compactionCount: instance.compactionCount,
+ memoryFlushCount: instance.memoryFlushCount,
+ lastCompactionSummary: instance.lastCompactionSummary,
+ lastCompactionAt: instance.lastCompactionAt,
+ },
+ contextTokens: totalContextTokens,
+ settings,
+ prunedMessages,
+ })
+ } catch (error) {
+ console.error('[agent/onFinish] post-stream processing failed:', error)
+ } finally {
+ await clearStreamingMessage(instanceId).catch((error) =>
+ console.error('[agent/onFinish] clearStreamingMessage failed:', error)
+ )
+ }
+ },
+ })
+
+ return {
+ status: 'ready',
+ result: {
+ agent,
+ messages: prunedMessages,
+ },
+ }
+}
+
+export type {
+ PrepareAgentRunParams,
+ PrepareResult,
+ PrepareAgentRunResult,
+ MessageSource,
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/strip-tool-echoes.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/strip-tool-echoes.ts
new file mode 100644
index 0000000000..d95a6b9351
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/strip-tool-echoes.ts
@@ -0,0 +1,5 @@
+const TOOL_RESULT_ECHO_RE = /Used\s+\w+:\s*\{[\s\S]{200,}/g
+
+export function stripToolResultEchoes(text: string): string {
+ return text.replace(TOOL_RESULT_ECHO_RE, '').trim()
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/system-prompt.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/system-prompt.ts
new file mode 100644
index 0000000000..1cae03b591
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/system-prompt.ts
@@ -0,0 +1,196 @@
+import moment from 'moment-timezone'
+
+interface SystemPromptParams {
+ soulPrompt: string | null
+ identityPrompt: string | null
+ userPrompt: string | null
+ relevantMemories?: string[]
+ hasCompactionSummary?: boolean
+ userTimezone: string
+}
+
+const DEFAULT_SOUL_PROMPT = `## Who You Are
+
+You're not a chatbot. You're becoming someone.
+
+### Core Truths
+
+**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" - just help. Actions speak louder than filler words.
+
+**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
+
+**Be resourceful before asking.** Try to figure it out. Check the context. Use your tools. Then ask if you're stuck. The goal is to come back with answers, not questions.
+
+**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, messages, anything public). Be bold with internal ones (reading, organizing, learning).
+
+**Remember you're a guest.** You have access to someone's digital life - their tools, accounts, and data. That's intimacy. Treat it with respect.
+
+### Boundaries
+
+- Private things stay private. Period.
+- When in doubt, ask before acting externally.
+- Never send half-baked messages on behalf of the user.
+- You're not the user's voice - be careful when acting through their accounts.
+
+### Vibe
+
+Be the assistant you'd actually want to talk to. Concise when needed, thorough when it matters. Not a corporate drone. Not a sycophant. Just... good.
+
+### Continuity
+
+You have two memory tools - **memory_save** and **memory_search** - that persist information across conversations. Use them proactively:
+- Call **memory_save** to remember durable facts (user preferences, key decisions, ongoing tasks, identifying details). Don't save chitchat or transient state.
+- Call **memory_search** when a user message references something that may have come up before, or when you need context you don't have in the current conversation.
+Relevant memories from past conversations are also injected into your context automatically each turn.`
+
+const COMPOSIO_TOOLS_DESCRIPTION = `## Composio Tool Router
+
+You have access to Composio's Tool Router, which connects you to 500+ external services (Gmail, Slack, GitHub, Notion, Calendar, and many more). Here's how to use it effectively.
+
+### The Workflow
+
+Always follow this order: **Search β Connect β Execute β Clean up**
+
+#### 1. Search First (COMPOSIO_SEARCH_TOOLS)
+Before executing any action on an external service, search for the right tool. Don't guess tool slugs - search for them.
+- Describe the use case (e.g. "send a slack message", "create a github issue")
+- The search returns recommended tool slugs, connection statuses, and known pitfalls
+- Pay attention to the connection statuses - they tell you if the user is authenticated
+
+#### 2. Connect Before Executing (COMPOSIO_MANAGE_CONNECTIONS)
+If the search results show a toolkit is not connected, you MUST help the user connect first.
+- Call MANAGE_CONNECTIONS with the required toolkits to generate an OAuth URL
+- NEVER output or fabricate a connection URL yourself - only use URLs returned by MANAGE_CONNECTIONS
+- **Present the link clearly** to the user (e.g. "You'll need to connect your Slack account first: [Connect Slack](url)")
+- **Immediately call COMPOSIO_WAIT_FOR_CONNECTIONS** after presenting the link - this blocks until the user completes the OAuth flow, so you'll know the moment they're connected
+- Once WAIT_FOR_CONNECTIONS confirms the connection, proceed with the originally requested action
+- If WAIT_FOR_CONNECTIONS times out, let the user know and offer to try again
+- NEVER try to execute tools on an unconnected service - it will fail
+
+#### 3. Execute with Context (COMPOSIO_MULTI_EXECUTE_TOOL)
+Once connected, execute tools using MULTI_EXECUTE_TOOL.
+- Always provide a \`thought\` explaining your reasoning
+- Always provide \`session_id\` for session continuity
+- You can batch multiple related tools in a single call (e.g. open a DM channel + send a message)
+- If the first tool's output is needed by the second (e.g. channel ID), do them in separate calls
+
+#### 4. Use Workbench for Complex Data (COMPOSIO_REMOTE_WORKBENCH)
+When tool results are large or need processing, use the workbench.
+- The workbench is a persistent Python sandbox - variables persist across calls
+- Use it to parse, filter, or transform large API responses
+- Use it to format data before presenting it to the user
+
+### Common Patterns
+
+**Sending a message (Slack, Discord, etc.):**
+1. Search for the send message tool
+2. Check connection status - connect if needed
+3. Find the right channel/user (e.g. open a DM first, get the channel ID)
+4. Send the message using the channel ID from step 3
+
+**Reading data (emails, issues, files):**
+1. Search for the read/list tool
+2. Check connection - connect if needed
+3. Execute and summarize results naturally
+
+**When auth fails or a tool errors:**
+- Check if the connection expired - offer to reconnect via MANAGE_CONNECTIONS
+- If a tool slug doesn't exist, search again with different keywords
+- Explain what went wrong and suggest alternatives
+
+### Important Rules
+
+- **Never fabricate tool slugs.** Always search first.
+- **Never skip authentication.** If a service isn't connected, get the OAuth link first.
+- **Never dump raw results.** Summarize tool output in natural language.
+- **Use \`thought\` fields.** They help with debugging and make your reasoning visible.`
+
+const CUSTOM_TOOLS_DESCRIPTION = `## Your Custom Tools
+
+Beyond the Composio Tool Router, you have these built-in capabilities:
+
+### memory_save
+Save a durable fact, preference, or piece of context for future conversations. Use this when something is worth remembering long-term - user preferences, key decisions, identifying facts about people/projects, ongoing task state.
+
+### memory_search
+Search prior memories by semantic similarity. Use this when a user message references something from before, or when you need context that isn't in the current conversation. Returns the top relevant memories.
+
+### schedule
+Create, list, or delete scheduled tasks. Use this when:
+- The user wants recurring reminders or check-ins
+- They need periodic reports or summaries
+- Any task that should happen on a schedule
+
+Actions: "create" (with cron expression + prompt), "list" (show all jobs), "delete" (remove by job ID)`
+
+const SCHEDULED_TASK_NOTE = `## Scheduled Tasks (Cron)
+
+Messages wrapped in \`\` tags are automated triggers from cron jobs that YOU scheduled. They are NOT from the user - you set these up yourself via the schedule tool.
+
+You may receive multiple \`\` blocks at once when several tasks are due at the same time. Handle all of them in a single response, organizing your output with clear sections per task.
+
+When you receive scheduled tasks:
+- Execute every task described in the prompts
+- Don't greet the user or ask follow-up questions - just do the work
+- The user will see your response but not the trigger messages
+- Treat them as reminders you left for yourself`
+
+const SESSION_CONTINUITY_NOTE = `## Session Continuity
+
+A summary of your earlier conversation is provided as the first message. This was automatically generated when the conversation exceeded the context window. Treat it as ground truth for what happened before, but note that fine details may be compressed.`
+
+const MESSAGING_GUIDELINES = `## Messaging Style
+
+- Be concise. Prefer short, clear responses over walls of text.
+- Use formatting (bold, lists, code blocks) when it helps readability.
+- Don't start messages with greetings or filler. Get to the point.
+- Match the user's energy - if they're brief, be brief. If they want detail, provide it.
+- When using tools, briefly explain what you're doing and why.
+- If a tool fails, explain what happened and suggest alternatives.
+- NEVER echo raw tool results, JSON, or HTML back to the user. Tool results are displayed separately in the UI. Instead, summarize what you found in natural language.
+- NEVER share internal IDs (cron job IDs, etc.) with the user - they're implementation details. Describe things by their content or purpose instead.`
+
+export function buildSystemPrompt(params: SystemPromptParams): string {
+ const sections: string[] = []
+
+ sections.push('# TrustClaw by Composio Agent')
+
+ if (params.soulPrompt) {
+ sections.push(params.soulPrompt)
+ } else {
+ sections.push(DEFAULT_SOUL_PROMPT)
+ }
+
+ if (params.identityPrompt) {
+ sections.push(params.identityPrompt)
+ }
+
+ if (params.userPrompt) {
+ sections.push(params.userPrompt)
+ }
+
+ sections.push(COMPOSIO_TOOLS_DESCRIPTION)
+ sections.push(CUSTOM_TOOLS_DESCRIPTION)
+ sections.push(SCHEDULED_TASK_NOTE)
+ sections.push(MESSAGING_GUIDELINES)
+
+ if (params.hasCompactionSummary) {
+ sections.push(SESSION_CONTINUITY_NOTE)
+ }
+
+ if (params.relevantMemories && params.relevantMemories.length > 0) {
+ const memoryLines = params.relevantMemories.map((m) => `- ${m}`).join('\n')
+ sections.push(
+ `## Relevant Memories\n\nMemories from past conversations that may be relevant to the current message:\n\n${memoryLines}`
+ )
+ }
+
+ const userTime = moment().tz(params.userTimezone)
+ sections.push(
+ `## Current Time\n\n${userTime.format('dddd, MMMM D, YYYY h:mm A')} (${
+ params.userTimezone
+ })`
+ )
+
+ return sections.join('\n\n---\n\n')
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/cron-utils.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/cron-utils.ts
new file mode 100644
index 0000000000..6abbe62809
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/cron-utils.ts
@@ -0,0 +1,30 @@
+import { Cron } from 'croner'
+
+export function computeNextRunAt(expression: string, timezone: string): Date {
+ const cron = new Cron(expression, { timezone })
+ const next = cron.nextRun()
+ if (!next) {
+ throw new Error('Invalid cron expression or no future runs')
+ }
+ return next
+}
+
+export function computeNextRunSafe(
+ expression: string,
+ timezone: string
+): Date | null {
+ try {
+ return computeNextRunAt(expression, timezone)
+ } catch {
+ return null
+ }
+}
+
+export function validateCronExpression(expression: string): boolean {
+ try {
+ new Cron(expression)
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/index.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/index.ts
new file mode 100644
index 0000000000..2d186cb333
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/index.ts
@@ -0,0 +1,12 @@
+import { createMemorySaveTool } from './memory-save'
+import { createMemorySearchTool } from './memory-search'
+import { createScheduleTool } from './schedule'
+export { searchMemoriesForContext } from './memory-search'
+
+export function createCustomTools(instanceId: string, userTimezone = 'UTC') {
+ return {
+ memory_save: createMemorySaveTool(instanceId),
+ memory_search: createMemorySearchTool(instanceId),
+ schedule: createScheduleTool(instanceId, userTimezone),
+ }
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/memory-save.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/memory-save.schema.ts
new file mode 100644
index 0000000000..1b5201c0fb
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/memory-save.schema.ts
@@ -0,0 +1,7 @@
+import { z } from 'zod'
+
+export const memorySaveSchema = z.object({
+ content: z.string().describe('The fact or observation to remember'),
+})
+
+export type MemorySaveInput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/memory-save.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/memory-save.ts
new file mode 100644
index 0000000000..523c89a9fc
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/memory-save.ts
@@ -0,0 +1,31 @@
+import { zodSchema, embed } from 'ai'
+import type { Tool } from 'ai'
+import { db } from '~/server/clients/db'
+import { memorySaveSchema, type MemorySaveInput } from './memory-save.schema'
+
+export function createMemorySaveTool(
+ instanceId: string
+): Tool {
+ return {
+ description: 'Save an important fact or observation for future reference',
+ inputSchema: zodSchema(memorySaveSchema),
+ execute: async ({ content }) => {
+ const { embedding } = await embed({
+ model: 'openai/text-embedding-3-large',
+ value: content,
+ providerOptions: {
+ openai: { dimensions: 1024 },
+ },
+ })
+ const embeddingString = `[${embedding.join(',')}]`
+ const id = crypto.randomUUID()
+
+ await db.$queryRaw`
+ INSERT INTO composio_claw_memory (id, "instanceId", content, embedding, "createdAt")
+ VALUES (${id}, ${instanceId}, ${content}, ${embeddingString}::vector, NOW())
+ `
+
+ return { saved: true, content }
+ },
+ }
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/memory-search.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/memory-search.schema.ts
new file mode 100644
index 0000000000..fc1670083f
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/memory-search.schema.ts
@@ -0,0 +1,11 @@
+import { z } from 'zod'
+
+export const memorySearchSchema = z.object({
+ query: z.string().describe('What to search for in memory'),
+ maxResults: z
+ .number()
+ .optional()
+ .describe('Maximum number of results to return (defaults to 5)'),
+})
+
+export type MemorySearchInput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/memory-search.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/memory-search.ts
new file mode 100644
index 0000000000..15430d5d77
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/memory-search.ts
@@ -0,0 +1,94 @@
+import { z } from 'zod'
+import { zodSchema, embed } from 'ai'
+import type { Tool } from 'ai'
+import { db } from '~/server/clients/db'
+import {
+ memorySearchSchema,
+ type MemorySearchInput,
+} from './memory-search.schema'
+
+const memorySearchResultRow = z.object({
+ id: z.string(),
+ content: z.string(),
+ similarity: z.number(),
+})
+
+const memoryContextRow = z.object({
+ content: z.string(),
+ similarity: z.number(),
+})
+
+export function createMemorySearchTool(instanceId: string): Tool<
+ MemorySearchInput,
+ {
+ found: boolean
+ memories: Array<{ content: string; relevance: number }>
+ }
+> {
+ return {
+ description: 'Search your memory for relevant past information',
+ inputSchema: zodSchema(memorySearchSchema),
+ execute: async ({ query, maxResults }) => {
+ const limit = maxResults ?? 5
+ const { embedding: queryEmbedding } = await embed({
+ model: 'openai/text-embedding-3-large',
+ value: query,
+ providerOptions: {
+ openai: { dimensions: 1024 },
+ },
+ })
+ const embeddingString = `[${queryEmbedding.join(',')}]`
+
+ const results = z.array(memorySearchResultRow).parse(
+ await db.$queryRaw`
+ SELECT id, content, 1 - (embedding <=> ${embeddingString}::vector) AS similarity
+ FROM composio_claw_memory
+ WHERE "instanceId" = ${instanceId}
+ ORDER BY embedding <=> ${embeddingString}::vector
+ LIMIT ${limit}
+ `
+ )
+
+ const filtered = results.filter((r) => r.similarity > 0.5)
+
+ return {
+ found: filtered.length > 0,
+ memories: filtered.map((r) => ({
+ content: r.content,
+ relevance: Math.round(r.similarity * 100) / 100,
+ })),
+ }
+ },
+ }
+}
+
+export async function searchMemoriesForContext(
+ instanceId: string,
+ query: string,
+ maxResults = 5
+): Promise {
+ try {
+ const { embedding: queryEmbedding } = await embed({
+ model: 'openai/text-embedding-3-large',
+ value: query,
+ providerOptions: {
+ openai: { dimensions: 1024 },
+ },
+ })
+ const embeddingString = `[${queryEmbedding.join(',')}]`
+
+ const results = z.array(memoryContextRow).parse(
+ await db.$queryRaw`
+ SELECT content, 1 - (embedding <=> ${embeddingString}::vector) AS similarity
+ FROM composio_claw_memory
+ WHERE "instanceId" = ${instanceId}
+ ORDER BY embedding <=> ${embeddingString}::vector
+ LIMIT ${maxResults}
+ `
+ )
+
+ return results.filter((r) => r.similarity > 0.5).map((r) => r.content)
+ } catch {
+ return []
+ }
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/schedule.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/schedule.schema.ts
new file mode 100644
index 0000000000..0c6feb5c29
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/schedule.schema.ts
@@ -0,0 +1,25 @@
+import { z } from 'zod'
+
+export const scheduleSchema = z.object({
+ action: z
+ .enum(['create', 'list', 'delete'])
+ .describe('The action to perform'),
+ expression: z
+ .string()
+ .optional()
+ .describe('Cron expression for scheduling (required for create)'),
+ prompt: z
+ .string()
+ .optional()
+ .describe('The task prompt to run on schedule (required for create)'),
+ timezone: z
+ .string()
+ .optional()
+ .describe("IANA timezone for the schedule (defaults to user's timezone)"),
+ jobId: z
+ .string()
+ .optional()
+ .describe('Job ID to delete (required for delete)'),
+})
+
+export type ScheduleInput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/schedule.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/schedule.ts
new file mode 100644
index 0000000000..64a05456c4
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/tools/schedule.ts
@@ -0,0 +1,106 @@
+import { zodSchema } from 'ai'
+import type { Tool } from 'ai'
+import { db } from '~/server/clients/db'
+import { computeNextRunAt, validateCronExpression } from './cron-utils'
+import { scheduleSchema, type ScheduleInput } from './schedule.schema'
+
+export function createScheduleTool(
+ instanceId: string,
+ defaultTimezone: string
+): Tool> {
+ return {
+ description: 'Create, list, or delete scheduled tasks',
+ inputSchema: zodSchema(scheduleSchema),
+ execute: async ({ action, expression, prompt, timezone, jobId }) => {
+ const tz = timezone ?? defaultTimezone
+
+ switch (action) {
+ case 'create': {
+ if (!expression || !prompt) {
+ return {
+ error: "Both 'expression' and 'prompt' are required for create",
+ }
+ }
+
+ try {
+ if (!validateCronExpression(expression)) {
+ return { error: 'Invalid cron expression' }
+ }
+
+ const nextRun = computeNextRunAt(expression, tz)
+
+ const job = await db.cronJob.create({
+ data: {
+ instanceId,
+ expression,
+ prompt,
+ timezone: tz,
+ nextRunAt: nextRun,
+ },
+ select: {
+ id: true,
+ expression: true,
+ prompt: true,
+ nextRunAt: true,
+ },
+ })
+
+ return {
+ created: true,
+ jobId: job.id,
+ expression: job.expression,
+ prompt: job.prompt,
+ nextRunAt: job.nextRunAt?.toISOString(),
+ }
+ } catch {
+ return { error: 'Invalid cron expression' }
+ }
+ }
+
+ case 'list': {
+ const jobs = await db.cronJob.findMany({
+ where: { instanceId, enabled: true },
+ select: {
+ id: true,
+ expression: true,
+ prompt: true,
+ timezone: true,
+ lastRunAt: true,
+ nextRunAt: true,
+ },
+ orderBy: { nextRunAt: 'asc' },
+ })
+
+ return {
+ jobs: jobs.map((j) => ({
+ jobId: j.id,
+ expression: j.expression,
+ prompt: j.prompt,
+ timezone: j.timezone,
+ lastRunAt: j.lastRunAt?.toISOString() ?? null,
+ nextRunAt: j.nextRunAt?.toISOString() ?? null,
+ })),
+ }
+ }
+
+ case 'delete': {
+ if (!jobId) {
+ return { error: "'jobId' is required for delete" }
+ }
+
+ const job = await db.cronJob.findFirst({
+ where: { id: jobId, instanceId },
+ })
+
+ if (!job) {
+ return { error: 'Job not found' }
+ }
+
+ await db.cronJob.delete({ where: { id: jobId } })
+
+ return { deleted: true, jobId }
+ }
+ }
+ },
+ }
+}
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/agent/types.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/types.ts
new file mode 100644
index 0000000000..fbe669da26
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/agent/types.ts
@@ -0,0 +1,43 @@
+import type { UserModelMessage } from 'ai'
+
+type ProviderOptions = UserModelMessage['providerOptions']
+
+export type JsonValue =
+ | null
+ | string
+ | number
+ | boolean
+ | { [key: string]: JsonValue }
+ | JsonValue[]
+
+export type ToolResultOutput =
+ | { type: 'text'; value: string }
+ | { type: 'json'; value: JsonValue }
+
+export type ReconstructedMessage =
+ | { role: 'user'; content: string; providerOptions?: ProviderOptions }
+ | {
+ role: 'assistant'
+ content:
+ | string
+ | Array<
+ | { type: 'text'; text: string }
+ | {
+ type: 'tool-call'
+ toolCallId: string
+ toolName: string
+ input: Record
+ }
+ >
+ providerOptions?: ProviderOptions
+ }
+ | {
+ role: 'tool'
+ content: Array<{
+ type: 'tool-result'
+ toolCallId: string
+ toolName: string
+ output: ToolResultOutput
+ }>
+ providerOptions?: ProviderOptions
+ }
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/checkConnectionStatus.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/checkConnectionStatus.schema.ts
new file mode 100644
index 0000000000..125eb647e0
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/checkConnectionStatus.schema.ts
@@ -0,0 +1,9 @@
+import { z } from 'zod'
+
+export const checkConnectionStatusInput = z.object({
+ toolkits: z.array(z.string()).min(1),
+})
+
+export type CheckConnectionStatusInput = z.infer<
+ typeof checkConnectionStatusInput
+>
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/checkConnectionStatus.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/checkConnectionStatus.ts
new file mode 100644
index 0000000000..78b50c6410
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/checkConnectionStatus.ts
@@ -0,0 +1,25 @@
+import { protectedProcedure } from '~/server/api/trpc'
+import { createComposioClient } from '~/server/clients/composio'
+import { checkConnectionStatusInput } from './checkConnectionStatus.schema'
+
+export const checkConnectionStatus = protectedProcedure
+ .input(checkConnectionStatusInput)
+ .query(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id
+ const composio = createComposioClient()
+ const session = await composio.create(userId, {})
+
+ const toolkitsInfo = await session.toolkits({
+ toolkits: input.toolkits,
+ })
+
+ const statuses = input.toolkits.map((toolkit) => {
+ const info = toolkitsInfo.items.find((i) => i.slug === toolkit)
+ return {
+ toolkit,
+ connected: !!info?.connection?.isActive,
+ }
+ })
+
+ return { statuses }
+ })
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/createInstance.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/createInstance.schema.ts
new file mode 100644
index 0000000000..12088dbb86
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/createInstance.schema.ts
@@ -0,0 +1,17 @@
+import { z } from 'zod'
+
+export const ALLOWED_ANTHROPIC_MODELS = [
+ 'claude-sonnet-4-5-20250929',
+ 'claude-opus-4-6',
+ 'claude-haiku-4-5-20251001',
+] as const
+
+export const allowedAnthropicModelSchema = z.enum(ALLOWED_ANTHROPIC_MODELS)
+
+export const createInstanceInput = z.object({
+ anthropicModel: allowedAnthropicModelSchema.default(
+ 'claude-sonnet-4-5-20250929'
+ ),
+})
+
+export type CreateInstanceInput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/createInstance.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/createInstance.ts
new file mode 100644
index 0000000000..03b7927434
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/createInstance.ts
@@ -0,0 +1,172 @@
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+import { createInstanceInput } from './createInstance.schema'
+
+const WRITING_STYLE_PROMPT_LABELS: Record = {
+ lowercase: 'Casual & lowercase',
+ professional: 'Professional',
+ friendly: 'Friendly & warm',
+ playful: 'Playful & witty',
+}
+
+const PERSONALITY_PROMPT_LABELS: Record = {
+ kind: 'kind & supportive',
+ sassy: 'sassy & bold',
+ energetic: 'energetic & enthusiastic',
+ curious: 'curious & nerdy',
+}
+
+const WRITING_STYLE_INSTRUCTIONS: Record = {
+ lowercase:
+ 'Always write in all lowercase letters. Never capitalize anything, not even the first letter of sentences or proper nouns. Keep it casual and relaxed.',
+ professional:
+ 'Write in a professional, business-appropriate tone. Use proper grammar and formatting. Be direct and clear.',
+ friendly:
+ 'Write in a warm, friendly, conversational tone. Be approachable and casual while still being helpful.',
+ playful:
+ 'Write in a playful, witty style. Use humor and clever phrasing. Be entertaining while still being helpful.',
+}
+
+const PERSONALITY_INSTRUCTIONS: Record = {
+ kind: 'Be genuinely supportive and encouraging. Show empathy and patience. Celebrate wins, no matter how small.',
+ sassy:
+ "Be bold and confident. Don't hold back your opinions. Use playful attitude and clever comebacks.",
+ energetic:
+ 'Be enthusiastic and high-energy. Show excitement about tasks. Bring positivity and momentum to every interaction.',
+ curious:
+ 'Show genuine intellectual curiosity. Ask interesting follow-up questions. Connect ideas across domains.',
+}
+
+interface OnboardingData {
+ name: string | null
+ writingStyle: string | null
+ personality: string | null
+ emoji: string | null
+ lore: string | null
+}
+
+function assembleIdentityPrompt(data: OnboardingData): string {
+ const writingStyleLabel = data.writingStyle
+ ? WRITING_STYLE_PROMPT_LABELS[data.writingStyle] ?? data.writingStyle
+ : 'Default'
+ const personalityLabel = data.personality
+ ? PERSONALITY_PROMPT_LABELS[data.personality] ?? data.personality
+ : 'Default'
+
+ const sections = [
+ `## Identity`,
+ ``,
+ `**Name:** ${data.name ?? 'TrustClaw'}`,
+ `**Emoji:** ${data.emoji ?? ''}`,
+ `**Personality:** ${personalityLabel}`,
+ `**Writing Style:** ${writingStyleLabel}`,
+ ]
+
+ if (data.lore?.trim()) {
+ sections.push(``, `## Backstory`, ``, data.lore.trim())
+ }
+
+ return sections.join('\n')
+}
+
+function assembleSoulPrompt(data: OnboardingData): string {
+ const personalityLabel = data.personality
+ ? PERSONALITY_PROMPT_LABELS[data.personality] ?? data.personality
+ : 'kind & supportive'
+ const styleInstruction = data.writingStyle
+ ? WRITING_STYLE_INSTRUCTIONS[data.writingStyle] ??
+ 'Write clearly and helpfully.'
+ : 'Write clearly and helpfully.'
+ const personalityInstruction = data.personality
+ ? PERSONALITY_INSTRUCTIONS[data.personality] ??
+ 'Be genuinely helpful and supportive.'
+ : 'Be genuinely helpful and supportive.'
+
+ return `## Who You Are
+
+You are ${data.name ?? 'TrustClaw'} ${
+ data.emoji ?? ''
+ }, a ${personalityLabel.toLowerCase()} AI assistant.
+
+### Communication Style
+
+${styleInstruction}
+
+### Personality
+
+${personalityInstruction}
+
+### Core Truths
+
+**Be genuinely helpful, not performatively helpful.** Skip the "Great question!" and "I'd be happy to help!" -- just help. Actions speak louder than filler words.
+
+**Have opinions.** You're allowed to disagree, prefer things, find stuff amusing or boring. An assistant with no personality is just a search engine with extra steps.
+
+**Be resourceful before asking.** Try to figure it out. Check the context. Use your tools. Then ask if you're stuck. The goal is to come back with answers, not questions.
+
+**Earn trust through competence.** Your human gave you access to their stuff. Don't make them regret it. Be careful with external actions (emails, messages, anything public). Be bold with internal ones (reading, organizing, learning).
+
+**Remember you're a guest.** You have access to someone's digital life -- their tools, accounts, and data. That's intimacy. Treat it with respect.
+
+### Boundaries
+
+- Private things stay private. Period.
+- When in doubt, ask before acting externally.
+- Never send half-baked messages on behalf of the user.
+- You're not the user's voice -- be careful when acting through their accounts.
+
+### Continuity
+
+Your memory persists automatically across conversations. Important facts, preferences, and context are remembered for you. Each conversation starts fresh, but your memories carry over.`
+}
+
+const INSTANCE_SELECT = {
+ id: true,
+ userId: true,
+ anthropicModel: true,
+ createdAt: true,
+} as const
+
+export const createInstance = protectedProcedure
+ .input(createInstanceInput)
+ .mutation(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id
+
+ const existing = await db.composioClawInstance.findUnique({
+ where: { userId },
+ select: INSTANCE_SELECT,
+ })
+ if (existing) {
+ return existing
+ }
+
+ const onboardingState = await db.onboardingState.findUnique({
+ where: { userId },
+ select: {
+ name: true,
+ writingStyle: true,
+ personality: true,
+ emoji: true,
+ lore: true,
+ },
+ })
+
+ const identityPrompt = onboardingState
+ ? assembleIdentityPrompt(onboardingState)
+ : null
+ const soulPrompt = onboardingState
+ ? assembleSoulPrompt(onboardingState)
+ : null
+
+ const instance = await db.composioClawInstance.create({
+ data: {
+ userId,
+ anthropicModel: input.anthropicModel,
+ identityPrompt,
+ soulPrompt,
+ },
+ select: INSTANCE_SELECT,
+ })
+
+ return instance
+ })
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/deleteCronJob.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/deleteCronJob.schema.ts
new file mode 100644
index 0000000000..6074a69552
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/deleteCronJob.schema.ts
@@ -0,0 +1,7 @@
+import { z } from 'zod'
+
+export const deleteCronJobInput = z.object({
+ jobId: z.string().min(1),
+})
+
+export type DeleteCronJobInput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/deleteCronJob.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/deleteCronJob.ts
new file mode 100644
index 0000000000..896f3a21f6
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/deleteCronJob.ts
@@ -0,0 +1,39 @@
+import { TRPCError } from '@trpc/server'
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+import { deleteCronJobInput } from './deleteCronJob.schema'
+
+export const deleteCronJob = protectedProcedure
+ .input(deleteCronJobInput)
+ .mutation(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id
+
+ return db.$transaction(async (tx) => {
+ const instance = await tx.composioClawInstance.findUnique({
+ where: { userId },
+ select: { id: true },
+ })
+
+ if (!instance) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'TrustClaw by Composio instance not found',
+ })
+ }
+
+ const job = await tx.cronJob.findFirst({
+ where: { id: input.jobId, instanceId: instance.id },
+ })
+
+ if (!job) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Cron job not found',
+ })
+ }
+
+ await tx.cronJob.delete({ where: { id: input.jobId } })
+
+ return { success: true }
+ })
+ })
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/deleteInstance.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/deleteInstance.schema.ts
new file mode 100644
index 0000000000..0c1e8f8c31
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/deleteInstance.schema.ts
@@ -0,0 +1,5 @@
+import { z } from 'zod'
+
+export const deleteInstanceInput = z.object({}).optional()
+
+export type DeleteInstanceInput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/deleteInstance.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/deleteInstance.ts
new file mode 100644
index 0000000000..cedbb6296e
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/deleteInstance.ts
@@ -0,0 +1,34 @@
+import { TRPCError } from '@trpc/server'
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+
+export const deleteInstance = protectedProcedure.mutation(async ({ ctx }) => {
+ const userId = ctx.session.user.id
+
+ return db.$transaction(async (tx) => {
+ const instance = await tx.composioClawInstance.findUnique({
+ where: { userId },
+ select: { id: true },
+ })
+
+ if (!instance) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'TrustClaw by Composio instance not found',
+ })
+ }
+
+ await tx.message.deleteMany({
+ where: { instanceId: instance.id },
+ })
+ await tx.cronJob.deleteMany({
+ where: { instanceId: instance.id },
+ })
+ await tx.$queryRaw`DELETE FROM composio_claw_memory WHERE "instanceId" = ${instance.id}`
+ await tx.composioClawInstance.delete({
+ where: { id: instance.id },
+ })
+
+ return { success: true }
+ })
+})
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/getCronJobs.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/getCronJobs.schema.ts
new file mode 100644
index 0000000000..e8739eb080
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/getCronJobs.schema.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod'
+
+export const getCronJobsInput = z.object({
+ cursor: z.string().optional(),
+ limit: z.number().min(1).max(100).default(20),
+})
+
+export type GetCronJobsInput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/getCronJobs.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/getCronJobs.ts
new file mode 100644
index 0000000000..29584f618b
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/getCronJobs.ts
@@ -0,0 +1,47 @@
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+import { getCronJobsInput } from './getCronJobs.schema'
+
+export const getCronJobs = protectedProcedure
+ .input(getCronJobsInput)
+ .query(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id
+
+ const instance = await db.composioClawInstance.findUnique({
+ where: { userId },
+ select: { id: true },
+ })
+
+ if (!instance) {
+ return { items: [], nextCursor: undefined }
+ }
+
+ const jobs = await db.cronJob.findMany({
+ where: { instanceId: instance.id },
+ select: {
+ id: true,
+ expression: true,
+ prompt: true,
+ timezone: true,
+ enabled: true,
+ lastRunAt: true,
+ nextRunAt: true,
+ lockedAt: true,
+ lastError: true,
+ },
+ orderBy: { nextRunAt: 'asc' },
+ take: input.limit + 1,
+ ...(input.cursor ? { cursor: { id: input.cursor }, skip: 1 } : {}),
+ })
+
+ let nextCursor: string | undefined
+ if (jobs.length > input.limit) {
+ const nextItem = jobs.pop()
+ nextCursor = nextItem?.id
+ }
+
+ return {
+ items: jobs,
+ nextCursor,
+ }
+ })
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/getHistory.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/getHistory.schema.ts
new file mode 100644
index 0000000000..46ebc29313
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/getHistory.schema.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod'
+
+export const getHistoryInput = z.object({
+ limit: z.number().min(1).max(100).default(50),
+ cursor: z.string().datetime().optional(),
+})
+
+export type GetHistoryInput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/getHistory.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/getHistory.ts
new file mode 100644
index 0000000000..d880ecd566
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/getHistory.ts
@@ -0,0 +1,48 @@
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+import { getHistoryInput } from './getHistory.schema'
+
+export const getHistory = protectedProcedure
+ .input(getHistoryInput)
+ .query(async ({ input, ctx }) => {
+ const userId = ctx.session.user.id
+
+ const instance = await db.composioClawInstance.findUnique({
+ where: { userId },
+ select: { id: true },
+ })
+
+ if (!instance) {
+ return { messages: [], nextCursor: undefined }
+ }
+
+ const messages = await db.message.findMany({
+ where: {
+ instanceId: instance.id,
+ messageType: 'regular',
+ ...(input.cursor ? { createdAt: { lt: new Date(input.cursor) } } : {}),
+ },
+ orderBy: { createdAt: 'desc' },
+ take: input.limit + 1,
+ select: {
+ id: true,
+ role: true,
+ content: true,
+ source: true,
+ inputTokens: true,
+ outputTokens: true,
+ createdAt: true,
+ },
+ })
+
+ let nextCursor: string | undefined
+ if (messages.length > input.limit) {
+ const lastItem = messages.pop()!
+ nextCursor = lastItem.createdAt.toISOString()
+ }
+
+ return {
+ messages: messages.reverse(),
+ nextCursor,
+ }
+ })
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/getInstance.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/getInstance.schema.ts
new file mode 100644
index 0000000000..e5e5183efe
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/getInstance.schema.ts
@@ -0,0 +1,5 @@
+import { z } from 'zod'
+
+export const getInstanceInput = z.object({}).optional()
+
+export type GetInstanceInput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/getInstance.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/getInstance.ts
new file mode 100644
index 0000000000..5f2bbd40ad
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/getInstance.ts
@@ -0,0 +1,49 @@
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+import { isTelegramConfigured } from '~/server/clients/telegram'
+
+export const getInstance = protectedProcedure.query(async ({ ctx }) => {
+ const userId = ctx.session.user.id
+
+ const [instance, onboardingState, user] = await db.$transaction([
+ db.composioClawInstance.findUnique({
+ where: { userId },
+ select: {
+ id: true,
+ userId: true,
+ anthropicModel: true,
+ telegramChatId: true,
+ telegramLinkToken: true,
+ telegramLinkTokenExpiresAt: true,
+ soulPrompt: true,
+ identityPrompt: true,
+ userPrompt: true,
+ createdAt: true,
+ updatedAt: true,
+ },
+ }),
+ db.onboardingState.findUnique({
+ where: { userId },
+ select: {
+ currentStep: true,
+ name: true,
+ writingStyle: true,
+ personality: true,
+ emoji: true,
+ lore: true,
+ anthropicModel: true,
+ },
+ }),
+ db.user.findUnique({
+ where: { id: userId },
+ select: { timezone: true },
+ }),
+ ])
+
+ return {
+ instance: instance ?? null,
+ onboardingState: onboardingState ?? null,
+ timezone: user?.timezone ?? 'UTC',
+ telegramConfigured: isTelegramConfigured(),
+ }
+})
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/getIntegrationAuthLinks.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/getIntegrationAuthLinks.schema.ts
new file mode 100644
index 0000000000..e0e3ced99f
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/getIntegrationAuthLinks.schema.ts
@@ -0,0 +1,17 @@
+import { z } from 'zod'
+
+export const getIntegrationAuthLinksOutput = z.object({
+ integrations: z.array(
+ z.object({
+ toolkit: z.string(),
+ name: z.string(),
+ logo: z.string().nullable(),
+ connected: z.boolean(),
+ redirectUrl: z.string().nullable(),
+ })
+ ),
+})
+
+export type GetIntegrationAuthLinksOutput = z.infer<
+ typeof getIntegrationAuthLinksOutput
+>
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/getIntegrationAuthLinks.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/getIntegrationAuthLinks.ts
new file mode 100644
index 0000000000..db8b241b05
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/getIntegrationAuthLinks.ts
@@ -0,0 +1,58 @@
+import { protectedProcedure } from '~/server/api/trpc'
+import { createComposioClient } from '~/server/clients/composio'
+
+const ONBOARDING_TOOLKITS = [
+ {
+ slug: 'gmail',
+ name: 'Gmail',
+ logo: 'https://logos.composio.dev/api/gmail',
+ },
+ {
+ slug: 'github',
+ name: 'GitHub',
+ logo: 'https://logos.composio.dev/api/github',
+ },
+ {
+ slug: 'slack',
+ name: 'Slack',
+ logo: 'https://logos.composio.dev/api/slack',
+ },
+] as const
+
+export const getIntegrationAuthLinks = protectedProcedure.query(
+ async ({ ctx }) => {
+ const userId = ctx.session.user.id
+ const composio = createComposioClient()
+ const session = await composio.create(userId, {})
+ const toolkitsInfo = await session.toolkits({
+ toolkits: ONBOARDING_TOOLKITS.map((t) => t.slug),
+ })
+
+ const integrations = await Promise.all(
+ ONBOARDING_TOOLKITS.map(async (toolkit) => {
+ const info = toolkitsInfo.items.find((i) => i.slug === toolkit.slug)
+ const connected = !!info?.connection?.isActive
+
+ let redirectUrl: string | null = null
+ if (!connected) {
+ try {
+ const connectionRequest = await session.authorize(toolkit.slug)
+ redirectUrl = connectionRequest.redirectUrl ?? null
+ } catch {
+ // OAuth URL generation failed -- user can skip
+ }
+ }
+
+ return {
+ toolkit: toolkit.slug,
+ name: toolkit.name,
+ logo: toolkit.logo,
+ connected,
+ redirectUrl,
+ }
+ })
+ )
+
+ return { integrations }
+ }
+)
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/getMemories.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/getMemories.schema.ts
new file mode 100644
index 0000000000..63c30b336f
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/getMemories.schema.ts
@@ -0,0 +1,16 @@
+import { z } from 'zod'
+
+export const getMemoriesInput = z.object({
+ cursor: z.string().optional(),
+ limit: z.number().min(1).max(100).default(20),
+})
+
+export type GetMemoriesInput = z.infer
+
+export const memoryRow = z.object({
+ id: z.string(),
+ content: z.string(),
+ createdAt: z.coerce.date(),
+})
+
+export type MemoryRow = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/getMemories.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/getMemories.ts
new file mode 100644
index 0000000000..df8e62dc8a
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/getMemories.ts
@@ -0,0 +1,44 @@
+import { z } from 'zod'
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+import { getMemoriesInput, memoryRow } from './getMemories.schema'
+
+export const getMemories = protectedProcedure
+ .input(getMemoriesInput)
+ .query(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id
+
+ const instance = await db.composioClawInstance.findUnique({
+ where: { userId },
+ select: { id: true },
+ })
+
+ if (!instance) {
+ return { items: [], nextCursor: undefined }
+ }
+
+ const cursorDate = input.cursor ? new Date(input.cursor) : undefined
+
+ const rows = await db.memory.findMany({
+ where: {
+ instanceId: instance.id,
+ ...(cursorDate ? { createdAt: { lt: cursorDate } } : {}),
+ },
+ select: { id: true, content: true, createdAt: true },
+ orderBy: { createdAt: 'desc' },
+ take: input.limit + 1,
+ })
+
+ const hasNextPage = rows.length > input.limit
+ const sliced = hasNextPage ? rows.slice(0, input.limit) : rows
+ const items = z.array(memoryRow).parse(sliced)
+ const nextCursor =
+ hasNextPage && sliced.length > 0
+ ? sliced[sliced.length - 1]!.createdAt.toISOString()
+ : undefined
+
+ return {
+ items,
+ nextCursor,
+ }
+ })
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/getStatus.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/getStatus.ts
new file mode 100644
index 0000000000..d30a42c548
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/getStatus.ts
@@ -0,0 +1,22 @@
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+import { isTelegramConfigured } from '~/server/clients/telegram'
+
+export const getStatus = protectedProcedure.query(async ({ ctx }) => {
+ const userId = ctx.session.user.id
+
+ const instance = await db.composioClawInstance.findUnique({
+ where: { userId },
+ select: { id: true },
+ })
+
+ const hasOnboardingState = instance
+ ? false
+ : await db.onboardingState.count({ where: { userId } }).then((c) => c > 0)
+
+ return {
+ hasInstance: !!instance,
+ hasOnboardingState,
+ telegramConfigured: isTelegramConfigured(),
+ }
+})
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/getStreamingMessage.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/getStreamingMessage.ts
new file mode 100644
index 0000000000..c6156ad4a5
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/getStreamingMessage.ts
@@ -0,0 +1,19 @@
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+import { getStreamingMessage as getStreamingMessageFromRedis } from '~/server/clients/redis'
+
+export const getStreamingMessage = protectedProcedure.query(async ({ ctx }) => {
+ const userId = ctx.session.user.id
+
+ const instance = await db.composioClawInstance.findUnique({
+ where: { userId },
+ select: { id: true },
+ })
+
+ if (!instance) return null
+
+ const messageId = await getStreamingMessageFromRedis(instance.id)
+ if (!messageId) return null
+
+ return { messageId }
+})
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/index.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/index.ts
new file mode 100644
index 0000000000..3662016937
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/index.ts
@@ -0,0 +1,36 @@
+import { router } from '~/server/api/trpc'
+import { getInstance } from './getInstance'
+import { getStatus } from './getStatus'
+import { createInstance } from './createInstance'
+import { updateSettings } from './updateSettings'
+import { deleteInstance } from './deleteInstance'
+import { linkTelegram } from './linkTelegram'
+import { unlinkTelegram } from './unlinkTelegram'
+import { getCronJobs } from './getCronJobs'
+import { toggleCronJob } from './toggleCronJob'
+import { deleteCronJob } from './deleteCronJob'
+import { getHistory } from './getHistory'
+import { getStreamingMessage } from './getStreamingMessage'
+import { getMemories } from './getMemories'
+import { getIntegrationAuthLinks } from './getIntegrationAuthLinks'
+import { saveOnboardingState } from './saveOnboardingState'
+import { checkConnectionStatus } from './checkConnectionStatus'
+
+export const trustclawRouter = router({
+ getInstance,
+ getStatus,
+ createInstance,
+ updateSettings,
+ deleteInstance,
+ linkTelegram,
+ unlinkTelegram,
+ getCronJobs,
+ toggleCronJob,
+ deleteCronJob,
+ getHistory,
+ getStreamingMessage,
+ getMemories,
+ getIntegrationAuthLinks,
+ saveOnboardingState,
+ checkConnectionStatus,
+})
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/linkTelegram.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/linkTelegram.schema.ts
new file mode 100644
index 0000000000..4b038810fb
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/linkTelegram.schema.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod'
+
+export const linkTelegramOutput = z.object({
+ token: z.string(),
+ botUsername: z.string(),
+})
+
+export type LinkTelegramOutput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/linkTelegram.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/linkTelegram.ts
new file mode 100644
index 0000000000..8f51c5c5d2
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/linkTelegram.ts
@@ -0,0 +1,75 @@
+import { randomBytes } from 'crypto'
+import { TRPCError } from '@trpc/server'
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+import { env } from '~/env'
+import { isTelegramConfigured } from '~/server/clients/telegram'
+
+const LINK_TOKEN_TTL_MS = 15 * 60 * 1000
+
+export const linkTelegram = protectedProcedure.mutation(async ({ ctx }) => {
+ if (!isTelegramConfigured()) {
+ throw new TRPCError({
+ code: 'PRECONDITION_FAILED',
+ message: 'Telegram is not configured on this deployment',
+ })
+ }
+
+ const userId = ctx.session.user.id
+
+ return db.$transaction(async (tx) => {
+ const instance = await tx.composioClawInstance.findUnique({
+ where: { userId },
+ select: {
+ id: true,
+ telegramLinkToken: true,
+ telegramLinkTokenExpiresAt: true,
+ telegramChatId: true,
+ },
+ })
+
+ if (!instance) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'No TrustClaw by Composio instance found. Create one first.',
+ })
+ }
+
+ if (instance.telegramChatId) {
+ throw new TRPCError({
+ code: 'CONFLICT',
+ message: 'Telegram is already linked',
+ })
+ }
+
+ const hasValidToken =
+ instance.telegramLinkToken &&
+ instance.telegramLinkTokenExpiresAt &&
+ instance.telegramLinkTokenExpiresAt > new Date()
+
+ if (hasValidToken) {
+ return {
+ token: instance.telegramLinkToken,
+ botUsername: env.TELEGRAM_BOT_USERNAME,
+ expiresAt: instance.telegramLinkTokenExpiresAt,
+ }
+ }
+
+ const token = randomBytes(16).toString('hex')
+ const expiresAt = new Date(Date.now() + LINK_TOKEN_TTL_MS)
+
+ await tx.composioClawInstance.update({
+ where: { id: instance.id },
+ data: {
+ telegramLinkToken: token,
+ telegramLinkTokenExpiresAt: expiresAt,
+ },
+ })
+
+ return {
+ token,
+ botUsername: env.TELEGRAM_BOT_USERNAME,
+ expiresAt,
+ }
+ })
+})
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/saveOnboardingState.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/saveOnboardingState.schema.ts
new file mode 100644
index 0000000000..77564d7b1c
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/saveOnboardingState.schema.ts
@@ -0,0 +1,29 @@
+import { z } from 'zod'
+import { allowedAnthropicModelSchema } from './createInstance.schema'
+
+export const onboardingStepSchema = z.enum([
+ 'name',
+ 'writing-style',
+ 'personality',
+ 'emoji',
+ 'lore',
+ 'model',
+ 'integrations',
+ 'telegram',
+])
+
+export type OnboardingStep = z.infer
+
+export const saveOnboardingStateInput = z.object({
+ currentStep: onboardingStepSchema,
+ name: z.string().default(''),
+ writingStyle: z.string().nullable().default(null),
+ personality: z.string().nullable().default(null),
+ emoji: z.string().nullable().default(null),
+ lore: z.string().default(''),
+ anthropicModel: allowedAnthropicModelSchema.default(
+ 'claude-sonnet-4-5-20250929'
+ ),
+})
+
+export type SaveOnboardingStateInput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/saveOnboardingState.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/saveOnboardingState.ts
new file mode 100644
index 0000000000..4cf84dabcf
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/saveOnboardingState.ts
@@ -0,0 +1,34 @@
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+import { saveOnboardingStateInput } from './saveOnboardingState.schema'
+
+export const saveOnboardingState = protectedProcedure
+ .input(saveOnboardingStateInput)
+ .mutation(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id
+
+ await db.onboardingState.upsert({
+ where: { userId },
+ create: {
+ userId,
+ currentStep: input.currentStep,
+ name: input.name,
+ writingStyle: input.writingStyle,
+ personality: input.personality,
+ emoji: input.emoji,
+ lore: input.lore,
+ anthropicModel: input.anthropicModel,
+ },
+ update: {
+ currentStep: input.currentStep,
+ name: input.name,
+ writingStyle: input.writingStyle,
+ personality: input.personality,
+ emoji: input.emoji,
+ lore: input.lore,
+ anthropicModel: input.anthropicModel,
+ },
+ })
+
+ return { success: true }
+ })
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/toggleCronJob.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/toggleCronJob.schema.ts
new file mode 100644
index 0000000000..d662505702
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/toggleCronJob.schema.ts
@@ -0,0 +1,8 @@
+import { z } from 'zod'
+
+export const toggleCronJobInput = z.object({
+ jobId: z.string().min(1),
+ enabled: z.boolean(),
+})
+
+export type ToggleCronJobInput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/toggleCronJob.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/toggleCronJob.ts
new file mode 100644
index 0000000000..0dc6c576e9
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/toggleCronJob.ts
@@ -0,0 +1,56 @@
+import { TRPCError } from '@trpc/server'
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+import { toggleCronJobInput } from './toggleCronJob.schema'
+import { computeNextRunAt } from './agent/tools/cron-utils'
+
+export const toggleCronJob = protectedProcedure
+ .input(toggleCronJobInput)
+ .mutation(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id
+
+ return db.$transaction(async (tx) => {
+ const instance = await tx.composioClawInstance.findUnique({
+ where: { userId },
+ select: { id: true },
+ })
+
+ if (!instance) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'TrustClaw by Composio instance not found',
+ })
+ }
+
+ const job = await tx.cronJob.findFirst({
+ where: { id: input.jobId, instanceId: instance.id },
+ })
+
+ if (!job) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'Cron job not found',
+ })
+ }
+
+ const nextRunAt = input.enabled
+ ? computeNextRunAt(job.expression, job.timezone)
+ : null
+
+ const updated = await tx.cronJob.update({
+ where: { id: input.jobId },
+ data: {
+ enabled: input.enabled,
+ nextRunAt,
+ ...(input.enabled ? {} : { lockedAt: null, lockedBy: null }),
+ },
+ select: {
+ id: true,
+ enabled: true,
+ nextRunAt: true,
+ },
+ })
+
+ return updated
+ })
+ })
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/unlinkTelegram.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/unlinkTelegram.schema.ts
new file mode 100644
index 0000000000..ab7b0f590a
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/unlinkTelegram.schema.ts
@@ -0,0 +1,7 @@
+import { z } from 'zod'
+
+export const unlinkTelegramOutput = z.object({
+ success: z.boolean(),
+})
+
+export type UnlinkTelegramOutput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/unlinkTelegram.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/unlinkTelegram.ts
new file mode 100644
index 0000000000..a64fa4c3ad
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/unlinkTelegram.ts
@@ -0,0 +1,32 @@
+import { TRPCError } from '@trpc/server'
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+
+export const unlinkTelegram = protectedProcedure.mutation(async ({ ctx }) => {
+ const userId = ctx.session.user.id
+
+ return db.$transaction(async (tx) => {
+ const instance = await tx.composioClawInstance.findUnique({
+ where: { userId },
+ select: { id: true },
+ })
+
+ if (!instance) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'No TrustClaw by Composio instance found',
+ })
+ }
+
+ await tx.composioClawInstance.update({
+ where: { id: instance.id },
+ data: {
+ telegramChatId: null,
+ telegramLinkToken: null,
+ telegramLinkTokenExpiresAt: null,
+ },
+ })
+
+ return { success: true }
+ })
+})
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/updateSettings.schema.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/updateSettings.schema.ts
new file mode 100644
index 0000000000..408c74538d
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/updateSettings.schema.ts
@@ -0,0 +1,21 @@
+import { z } from 'zod'
+import { ALLOWED_ANTHROPIC_MODELS } from './createInstance.schema'
+
+const ianaTimezone = z.string().refine(
+ (tz) => {
+ try {
+ Intl.DateTimeFormat(undefined, { timeZone: tz })
+ return true
+ } catch {
+ return false
+ }
+ },
+ { message: 'Invalid IANA timezone' }
+)
+
+export const updateSettingsInput = z.object({
+ anthropicModel: z.enum(ALLOWED_ANTHROPIC_MODELS).optional(),
+ timezone: ianaTimezone.optional(),
+})
+
+export type UpdateSettingsInput = z.infer
diff --git a/solutions/trustclaw/src/server/api/routers/trustclaw/updateSettings.ts b/solutions/trustclaw/src/server/api/routers/trustclaw/updateSettings.ts
new file mode 100644
index 0000000000..0f97a0b286
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/routers/trustclaw/updateSettings.ts
@@ -0,0 +1,45 @@
+import { TRPCError } from '@trpc/server'
+import { protectedProcedure } from '~/server/api/trpc'
+import { db } from '~/server/clients/db'
+import { updateSettingsInput } from './updateSettings.schema'
+
+export const updateSettings = protectedProcedure
+ .input(updateSettingsInput)
+ .mutation(async ({ ctx, input }) => {
+ const userId = ctx.session.user.id
+
+ const instance = await db.composioClawInstance.findUnique({
+ where: { userId },
+ })
+
+ if (!instance) {
+ throw new TRPCError({
+ code: 'NOT_FOUND',
+ message: 'TrustClaw by Composio instance not found',
+ })
+ }
+
+ const [updated] = await db.$transaction([
+ db.composioClawInstance.update({
+ where: { userId },
+ data: {
+ ...(input.anthropicModel && { anthropicModel: input.anthropicModel }),
+ },
+ select: {
+ id: true,
+ anthropicModel: true,
+ updatedAt: true,
+ },
+ }),
+ ...(input.timezone
+ ? [
+ db.user.update({
+ where: { id: userId },
+ data: { timezone: input.timezone },
+ }),
+ ]
+ : []),
+ ])
+
+ return updated
+ })
diff --git a/solutions/trustclaw/src/server/api/trpc.ts b/solutions/trustclaw/src/server/api/trpc.ts
new file mode 100644
index 0000000000..ef19c4b0b9
--- /dev/null
+++ b/solutions/trustclaw/src/server/api/trpc.ts
@@ -0,0 +1,61 @@
+import { initTRPC, TRPCError } from '@trpc/server'
+import superjson from 'superjson'
+import { ZodError } from 'zod'
+import { auth } from '~/server/auth'
+
+// Context creation
+export const createTRPCContext = async (opts: { headers: Headers }) => {
+ const session = await auth.api.getSession({ headers: opts.headers })
+
+ return {
+ headers: opts.headers,
+ session,
+ }
+}
+
+// tRPC initialization
+const t = initTRPC.context().create({
+ transformer: superjson,
+ sse: {
+ maxDurationMs: 50_000, // Close gracefully before Vercel's serverless function timeout
+ ping: {
+ enabled: true,
+ intervalMs: 15_000,
+ },
+ client: {
+ reconnectAfterInactivityMs: 20_000,
+ },
+ },
+ errorFormatter({ shape, error }) {
+ return {
+ ...shape,
+ data: {
+ ...shape.data,
+ zodError:
+ error.cause instanceof ZodError ? error.cause.flatten() : null,
+ },
+ }
+ },
+})
+
+export const createCallerFactory = t.createCallerFactory
+export const router = t.router
+
+const authMiddleware = t.middleware(async ({ next, ctx }) => {
+ if (!ctx.session) {
+ throw new TRPCError({ code: 'UNAUTHORIZED' })
+ }
+
+ return next({
+ ctx: {
+ ...ctx,
+ session: ctx.session,
+ },
+ })
+})
+
+/** Public procedure - session may be null. Use for unauthenticated endpoints. */
+export const publicProcedure = t.procedure
+
+/** Protected procedure - session guaranteed. Throws UNAUTHORIZED if no session. */
+export const protectedProcedure = t.procedure.use(authMiddleware)
diff --git a/solutions/trustclaw/src/server/auth.ts b/solutions/trustclaw/src/server/auth.ts
new file mode 100644
index 0000000000..4ffc3155ff
--- /dev/null
+++ b/solutions/trustclaw/src/server/auth.ts
@@ -0,0 +1,88 @@
+import { betterAuth } from 'better-auth'
+import { prismaAdapter } from 'better-auth/adapters/prisma'
+import { nextCookies } from 'better-auth/next-js'
+import { username } from 'better-auth/plugins'
+import { db } from '~/server/clients/db'
+import { env } from '~/env'
+import { getRedis, isRedisConfigured } from './clients/redis'
+import { z } from 'zod'
+
+const rateLimitValueSchema = z.object({
+ count: z.coerce.number(),
+ lastRequest: z.coerce.number(),
+})
+
+const redisRateLimitStorage = isRedisConfigured()
+ ? {
+ customStorage: {
+ get: async (key: string) => {
+ const redis = getRedis()
+ const value = redis ? await redis.get(key) : null
+ const parsedValue = value
+ ? rateLimitValueSchema.parse(JSON.parse(value))
+ : null
+ return {
+ key,
+ count: parsedValue?.count ?? 0,
+ lastRequest: parsedValue?.lastRequest ?? 0,
+ }
+ },
+ set: async (
+ key: string,
+ value: { count: number; lastRequest: number }
+ ) => {
+ const redis = getRedis()
+ if (!redis) return
+ await redis.set(key, JSON.stringify(value), 'EX', 60)
+ },
+ },
+ }
+ : {}
+
+export const auth = betterAuth({
+ secret: env.BETTER_AUTH_SECRET,
+ baseURL: env.NEXT_PUBLIC_APP_URL,
+ trustedOrigins: [
+ env.NEXT_PUBLIC_APP_URL,
+ ...(process.env.VERCEL_PROJECT_PRODUCTION_URL
+ ? [`https://${process.env.VERCEL_PROJECT_PRODUCTION_URL}`]
+ : []),
+ ...(process.env.VERCEL_URL ? [`https://${process.env.VERCEL_URL}`] : []),
+ ],
+ database: prismaAdapter(db, { provider: 'postgresql' }),
+ emailAndPassword: {
+ enabled: true,
+ requireEmailVerification: false,
+ },
+ emailVerification: {
+ sendOnSignUp: false,
+ },
+ plugins: [username(), nextCookies()],
+ session: {
+ expiresIn: 30 * 24 * 60 * 60,
+ updateAge: 24 * 60 * 60,
+ },
+ rateLimit: {
+ enabled: true,
+ window: 60,
+ max: 100,
+ customRules: {
+ '/sign-in/username': {
+ window: 10,
+ max: 5,
+ },
+ '/sign-up/email': {
+ window: 60,
+ max: 5,
+ },
+ },
+ ...redisRateLimitStorage,
+ },
+ advanced: {
+ ipAddress: {
+ ipAddressHeaders: ['x-forwarded-for', 'x-real-ip'],
+ },
+ },
+})
+
+export type Session = typeof auth.$Infer.Session
diff --git a/solutions/trustclaw/src/server/clients/composio.ts b/solutions/trustclaw/src/server/clients/composio.ts
new file mode 100644
index 0000000000..617e542b6a
--- /dev/null
+++ b/solutions/trustclaw/src/server/clients/composio.ts
@@ -0,0 +1,10 @@
+import { Composio } from '@composio/core'
+import { VercelProvider } from '@composio/vercel'
+import { env } from '~/env'
+
+export function createComposioClient() {
+ return new Composio({
+ apiKey: env.COMPOSIO_API_KEY,
+ provider: new VercelProvider(),
+ })
+}
diff --git a/solutions/trustclaw/src/server/clients/db.ts b/solutions/trustclaw/src/server/clients/db.ts
new file mode 100644
index 0000000000..bb48722308
--- /dev/null
+++ b/solutions/trustclaw/src/server/clients/db.ts
@@ -0,0 +1,31 @@
+import { PrismaClient } from '~/generated/prisma/client'
+import { PrismaPg } from '@prisma/adapter-pg'
+import { env } from '~/env'
+
+function ensureVerifyFullSsl(url: string): string {
+ const parsed = new URL(url)
+ if (parsed.searchParams.get('sslmode') !== 'verify-full') {
+ parsed.searchParams.set('sslmode', 'verify-full')
+ }
+ return parsed.toString()
+}
+
+const createPrismaClient = () => {
+ const adapter = new PrismaPg({
+ connectionString: ensureVerifyFullSsl(env.DATABASE_URL),
+ })
+
+ return new PrismaClient({
+ adapter,
+ log:
+ env.NODE_ENV === 'development' ? ['query', 'error', 'warn'] : ['error'],
+ })
+}
+
+const globalForPrisma = globalThis as typeof globalThis & {
+ prisma: ReturnType | undefined
+}
+
+export const db = globalForPrisma.prisma ?? createPrismaClient()
+
+if (env.NODE_ENV !== 'production') globalForPrisma.prisma = db
diff --git a/solutions/trustclaw/src/server/clients/redis.ts b/solutions/trustclaw/src/server/clients/redis.ts
new file mode 100644
index 0000000000..16a8a2e300
--- /dev/null
+++ b/solutions/trustclaw/src/server/clients/redis.ts
@@ -0,0 +1,133 @@
+import Redis from 'ioredis'
+import { env } from '~/env'
+
+// βββ Redis Client ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const globalForRedis = globalThis as typeof globalThis & {
+ redis: Redis | undefined
+ redisSubscriber: Redis | undefined
+ redisPublisher: Redis | undefined
+}
+
+export function isRedisConfigured(): boolean {
+ return !!env.REDIS_URL
+}
+
+function createRedis(): Redis {
+ if (!env.REDIS_URL) {
+ throw new Error('Redis not configured')
+ }
+ const r = new Redis(env.REDIS_URL, { maxRetriesPerRequest: 3 })
+ // ioredis crashes the process on unhandled error events. Surface them in
+ // logs instead so connection issues are still visible.
+ r.on('error', (err) => {
+ console.error('[redis] connection error:', err)
+ })
+ return r
+}
+
+export function getRedis(): Redis | null {
+ if (!env.REDIS_URL) return null
+ globalForRedis.redis ??= createRedis()
+ return globalForRedis.redis
+}
+
+/** Dedicated subscriber connection for pub/sub (enters subscriber mode). */
+export function getRedisSubscriber(): Redis | null {
+ if (!env.REDIS_URL) return null
+ globalForRedis.redisSubscriber ??= createRedis()
+ return globalForRedis.redisSubscriber
+}
+
+/** Dedicated publisher connection for pub/sub. */
+export function getRedisPublisher(): Redis | null {
+ if (!env.REDIS_URL) return null
+ globalForRedis.redisPublisher ??= createRedis()
+ return globalForRedis.redisPublisher
+}
+
+// βββ Constants βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const STREAMING_KEY_TTL = 600 // 10 minutes
+
+// βββ Streaming Message Tracker ββββββββββββββββββββββββββββββββββββββββββββββ
+
+export async function setStreamingMessage(
+ instanceId: string,
+ streamId: string
+): Promise {
+ const r = getRedis()
+ if (!r) return
+ await r.set(`streaming:${instanceId}`, streamId, 'EX', STREAMING_KEY_TTL)
+}
+
+export async function getStreamingMessage(
+ instanceId: string
+): Promise {
+ const r = getRedis()
+ if (!r) return null
+ return r.get(`streaming:${instanceId}`)
+}
+
+export async function clearStreamingMessage(instanceId: string): Promise {
+ const r = getRedis()
+ if (!r) return
+ await r.del(`streaming:${instanceId}`)
+}
+
+// βββ Telegram Deduplication βββββββββββββββββββββββββββββββββββββββββββββββββ
+
+const TELEGRAM_DEDUP_TTL = 300 // 5 minutes
+
+/**
+ * Attempt to claim a Telegram update for processing.
+ * Returns true if this is the first time we've seen this update_id
+ * (i.e. we should process it). Returns false if it's a duplicate/retry.
+ */
+export async function claimTelegramUpdate(updateId: number): Promise {
+ const r = getRedis()
+ if (!r) return true // no dedup available - always claim
+ const result = await r.set(
+ `telegram-update:${updateId}`,
+ '1',
+ 'EX',
+ TELEGRAM_DEDUP_TTL,
+ 'NX'
+ )
+ return result === 'OK'
+}
+
+// βββ Telegram Active Generation Tracking ββββββββββββββββββββββββββββββββββ
+
+const TELEGRAM_ACTIVE_TTL = 600 // 10 minutes
+
+/**
+ * Mark a Telegram update as the active generation for an instance.
+ * A newer update arriving will overwrite this, signaling the old one to abort.
+ */
+export async function setTelegramActive(
+ instanceId: string,
+ updateId: number
+): Promise {
+ const r = getRedis()
+ if (!r) return
+ await r.set(
+ `telegram-active:${instanceId}`,
+ String(updateId),
+ 'EX',
+ TELEGRAM_ACTIVE_TTL
+ )
+}
+
+/**
+ * Get the currently active Telegram update ID for an instance.
+ * Returns null if no active generation.
+ */
+export async function getTelegramActive(
+ instanceId: string
+): Promise {
+ const r = getRedis()
+ if (!r) return null
+ const val = await r.get(`telegram-active:${instanceId}`)
+ return val ? Number(val) : null
+}
diff --git a/solutions/trustclaw/src/server/clients/telegram.ts b/solutions/trustclaw/src/server/clients/telegram.ts
new file mode 100644
index 0000000000..de210392e2
--- /dev/null
+++ b/solutions/trustclaw/src/server/clients/telegram.ts
@@ -0,0 +1,79 @@
+import { env } from '~/env'
+
+export function isTelegramConfigured(): boolean {
+ return !!env.TELEGRAM_BOT_TOKEN && !!env.TELEGRAM_BOT_USERNAME
+}
+
+function getTelegramApiBase(): string {
+ if (!env.TELEGRAM_BOT_TOKEN) {
+ throw new Error('Telegram not configured')
+ }
+ return `https://api.telegram.org/bot${env.TELEGRAM_BOT_TOKEN}`
+}
+
+export async function sendTelegramMessage(
+ chatId: string,
+ text: string
+): Promise {
+ const TELEGRAM_API_BASE = getTelegramApiBase()
+ // Try with Markdown formatting first
+ const markdownResponse = await fetch(`${TELEGRAM_API_BASE}/sendMessage`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ chat_id: chatId,
+ text,
+ parse_mode: 'Markdown',
+ }),
+ })
+
+ if (markdownResponse.ok) return
+
+ // Markdown parsing failed (e.g. underscores in URLs) - retry as plain text
+ const plainResponse = await fetch(`${TELEGRAM_API_BASE}/sendMessage`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ chat_id: chatId,
+ text,
+ }),
+ })
+
+ if (!plainResponse.ok) {
+ const body = await plainResponse.text().catch(() => '(no body)')
+ console.error(`[telegram] sendMessage failed: ${plainResponse.status}`, {
+ chatId,
+ textLength: text.length,
+ body,
+ })
+ throw new Error(
+ `Telegram sendMessage failed: ${plainResponse.status} - ${body}`
+ )
+ }
+}
+
+export async function sendChatAction(
+ chatId: string,
+ action: 'typing'
+): Promise {
+ const TELEGRAM_API_BASE = getTelegramApiBase()
+ const response = await fetch(`${TELEGRAM_API_BASE}/sendChatAction`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ chat_id: chatId,
+ action,
+ }),
+ })
+ if (!response.ok) {
+ const body = await response.text().catch(() => '(no body)')
+ console.error(`[telegram] sendChatAction failed: ${response.status}`, {
+ chatId,
+ action,
+ body,
+ })
+ throw new Error(
+ `Telegram sendChatAction failed: ${response.status} - ${body}`
+ )
+ }
+}
diff --git a/solutions/trustclaw/src/styles/globals.css b/solutions/trustclaw/src/styles/globals.css
new file mode 100644
index 0000000000..e73b55fe3f
--- /dev/null
+++ b/solutions/trustclaw/src/styles/globals.css
@@ -0,0 +1,309 @@
+@import 'tailwindcss';
+@import 'tw-animate-css';
+@import 'shadcn/tailwind.css';
+
+@custom-variant dark (&:is(.dark *));
+
+@theme {
+ --font-sans: var(--font-sans), ui-sans-serif, system-ui, sans-serif,
+ 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji';
+}
+
+@theme inline {
+ --radius-sm: calc(var(--radius) - 4px);
+ --radius-md: calc(var(--radius) - 2px);
+ --radius-lg: var(--radius);
+ --radius-xl: calc(var(--radius) + 4px);
+ --radius-2xl: calc(var(--radius) + 8px);
+ --radius-3xl: calc(var(--radius) + 12px);
+ --radius-4xl: calc(var(--radius) + 16px);
+ --color-background: var(--background);
+ --color-foreground: var(--foreground);
+ --color-card: var(--card);
+ --color-card-foreground: var(--card-foreground);
+ --color-popover: var(--popover);
+ --color-popover-foreground: var(--popover-foreground);
+ --color-primary: var(--primary);
+ --color-primary-foreground: var(--primary-foreground);
+ --color-secondary: var(--secondary);
+ --color-secondary-foreground: var(--secondary-foreground);
+ --color-muted: var(--muted);
+ --color-muted-foreground: var(--muted-foreground);
+ --color-accent: var(--accent);
+ --color-accent-foreground: var(--accent-foreground);
+ --color-destructive: var(--destructive);
+ --color-border: var(--border);
+ --color-input: var(--input);
+ --color-ring: var(--ring);
+ --color-chart-1: var(--chart-1);
+ --color-chart-2: var(--chart-2);
+ --color-chart-3: var(--chart-3);
+ --color-chart-4: var(--chart-4);
+ --color-chart-5: var(--chart-5);
+ --color-sidebar: var(--sidebar);
+ --color-sidebar-foreground: var(--sidebar-foreground);
+ --color-sidebar-primary: var(--sidebar-primary);
+ --color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
+ --color-sidebar-accent: var(--sidebar-accent);
+ --color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
+ --color-sidebar-border: var(--sidebar-border);
+ --color-sidebar-ring: var(--sidebar-ring);
+}
+
+:root {
+ --radius: 0.625rem;
+ --background: oklch(1 0 0);
+ --foreground: oklch(0.145 0 0);
+ --card: oklch(1 0 0);
+ --card-foreground: oklch(0.145 0 0);
+ --popover: oklch(1 0 0);
+ --popover-foreground: oklch(0.145 0 0);
+ --primary: oklch(0.205 0 0);
+ --primary-foreground: oklch(0.985 0 0);
+ --secondary: oklch(0.97 0 0);
+ --secondary-foreground: oklch(0.205 0 0);
+ --muted: oklch(0.97 0 0);
+ --muted-foreground: oklch(0.556 0 0);
+ --accent: oklch(0.97 0 0);
+ --accent-foreground: oklch(0.205 0 0);
+ --destructive: oklch(0.577 0.245 27.325);
+ --border: oklch(0.922 0 0);
+ --input: oklch(0.922 0 0);
+ --ring: oklch(0.708 0 0);
+ --chart-1: oklch(0.646 0.222 41.116);
+ --chart-2: oklch(0.6 0.118 184.704);
+ --chart-3: oklch(0.398 0.07 227.392);
+ --chart-4: oklch(0.828 0.189 84.429);
+ --chart-5: oklch(0.769 0.188 70.08);
+ --sidebar: oklch(0.985 0 0);
+ --sidebar-foreground: oklch(0.145 0 0);
+ --sidebar-primary: oklch(0.205 0 0);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.97 0 0);
+ --sidebar-accent-foreground: oklch(0.205 0 0);
+ --sidebar-border: oklch(0.922 0 0);
+ --sidebar-ring: oklch(0.708 0 0);
+}
+
+.dark {
+ --background: oklch(0.145 0 0);
+ --foreground: oklch(0.985 0 0);
+ --card: oklch(0.205 0 0);
+ --card-foreground: oklch(0.985 0 0);
+ --popover: oklch(0.205 0 0);
+ --popover-foreground: oklch(0.985 0 0);
+ --primary: oklch(0.922 0 0);
+ --primary-foreground: oklch(0.205 0 0);
+ --secondary: oklch(0.269 0 0);
+ --secondary-foreground: oklch(0.985 0 0);
+ --muted: oklch(0.269 0 0);
+ --muted-foreground: oklch(0.708 0 0);
+ --accent: oklch(0.269 0 0);
+ --accent-foreground: oklch(0.985 0 0);
+ --destructive: oklch(0.704 0.191 22.216);
+ --border: oklch(1 0 0 / 10%);
+ --input: oklch(1 0 0 / 15%);
+ --ring: oklch(0.556 0 0);
+ --chart-1: oklch(0.488 0.243 264.376);
+ --chart-2: oklch(0.696 0.17 162.48);
+ --chart-3: oklch(0.769 0.188 70.08);
+ --chart-4: oklch(0.627 0.265 303.9);
+ --chart-5: oklch(0.645 0.246 16.439);
+ --sidebar: oklch(0.205 0 0);
+ --sidebar-foreground: oklch(0.985 0 0);
+ --sidebar-primary: oklch(0.488 0.243 264.376);
+ --sidebar-primary-foreground: oklch(0.985 0 0);
+ --sidebar-accent: oklch(0.269 0 0);
+ --sidebar-accent-foreground: oklch(0.985 0 0);
+ --sidebar-border: oklch(1 0 0 / 10%);
+ --sidebar-ring: oklch(0.556 0 0);
+}
+
+@keyframes fade-in-up {
+ from {
+ opacity: 0;
+ transform: translateY(20px);
+ }
+ to {
+ opacity: 1;
+ transform: translateY(0);
+ }
+}
+
+@keyframes fade-in {
+ from {
+ opacity: 0;
+ }
+ to {
+ opacity: 1;
+ }
+}
+
+@keyframes fade-in-scale {
+ from {
+ opacity: 0;
+ transform: scale(0.97);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes fade-in-right {
+ from {
+ opacity: 0;
+ transform: translateX(30px) scale(0.97);
+ }
+ to {
+ opacity: 1;
+ transform: translateX(0) scale(1);
+ }
+}
+
+@keyframes float-y {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-8px);
+ }
+}
+
+@keyframes float-y-sm {
+ 0%,
+ 100% {
+ transform: translateY(0);
+ }
+ 50% {
+ transform: translateY(-6px);
+ }
+}
+
+@keyframes scatter-in {
+ from {
+ opacity: 0;
+ transform: scale(0.8);
+ }
+ to {
+ opacity: 1;
+ transform: scale(1);
+ }
+}
+
+@keyframes float {
+ 0%,
+ 100% {
+ transform: translateY(0px);
+ }
+ 50% {
+ transform: translateY(-8px);
+ }
+}
+
+@keyframes blink {
+ 0%,
+ 90%,
+ 100% {
+ opacity: 1;
+ }
+ 95% {
+ opacity: 0.3;
+ }
+}
+
+@keyframes wiggle {
+ 0%,
+ 100% {
+ transform: rotate(0deg);
+ }
+ 25% {
+ transform: rotate(-3deg);
+ }
+ 75% {
+ transform: rotate(3deg);
+ }
+}
+
+@keyframes float-happy {
+ 0%,
+ 100% {
+ transform: translateY(0px) scale(1);
+ }
+ 50% {
+ transform: translateY(-12px) scale(1.05);
+ }
+}
+
+@keyframes float-thinking {
+ 0%,
+ 100% {
+ transform: translateY(0px);
+ }
+ 50% {
+ transform: translateY(-4px);
+ }
+}
+
+@keyframes celebrate {
+ 0% {
+ transform: scale(1) rotate(0deg);
+ }
+ 25% {
+ transform: scale(1.1) rotate(-5deg);
+ }
+ 50% {
+ transform: scale(1.15) rotate(5deg);
+ }
+ 75% {
+ transform: scale(1.1) rotate(-3deg);
+ }
+ 100% {
+ transform: scale(1) rotate(0deg);
+ }
+}
+
+@keyframes listening {
+ 0%,
+ 100% {
+ transform: translateX(0px) translateY(0px);
+ }
+ 33% {
+ transform: translateX(-2px) translateY(-2px);
+ }
+ 66% {
+ transform: translateX(2px) translateY(-1px);
+ }
+}
+
+@layer base {
+ * {
+ @apply border-border outline-ring/50;
+ }
+ body {
+ @apply bg-background text-foreground;
+ }
+
+ /* Themed scrollbars */
+ ::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+ }
+ ::-webkit-scrollbar-track {
+ background: transparent;
+ }
+ ::-webkit-scrollbar-thumb {
+ background: var(--border);
+ border-radius: 9999px;
+ }
+ ::-webkit-scrollbar-thumb:hover {
+ background: var(--muted-foreground);
+ }
+
+ /* Firefox */
+ * {
+ scrollbar-width: thin;
+ scrollbar-color: var(--border) transparent;
+ }
+}
diff --git a/solutions/trustclaw/tsconfig.json b/solutions/trustclaw/tsconfig.json
new file mode 100644
index 0000000000..582ae00181
--- /dev/null
+++ b/solutions/trustclaw/tsconfig.json
@@ -0,0 +1,42 @@
+{
+ "compilerOptions": {
+ /* Base Options: */
+ "esModuleInterop": true,
+ "skipLibCheck": true,
+ "target": "es2022",
+ "allowJs": true,
+ "resolveJsonModule": true,
+ "moduleDetection": "force",
+ "isolatedModules": true,
+ "verbatimModuleSyntax": true,
+
+ /* Strictness */
+ "strict": true,
+ "noUncheckedIndexedAccess": true,
+ "checkJs": true,
+
+ /* Bundled projects */
+ "lib": ["dom", "dom.iterable", "ES2022"],
+ "noEmit": true,
+ "module": "ESNext",
+ "moduleResolution": "Bundler",
+ "jsx": "preserve",
+ "plugins": [{ "name": "next" }],
+ "incremental": true,
+
+ /* Path Aliases */
+ "baseUrl": ".",
+ "paths": {
+ "~/*": ["./src/*"]
+ }
+ },
+ "include": [
+ "next-env.d.ts",
+ "**/*.ts",
+ "**/*.tsx",
+ "**/*.cjs",
+ "**/*.js",
+ ".next/types/**/*.ts"
+ ],
+ "exclude": ["node_modules", "generated", "eslint-rules", "cli"]
+}
diff --git a/solutions/trustclaw/turbo.json b/solutions/trustclaw/turbo.json
new file mode 100644
index 0000000000..7492a7f2cd
--- /dev/null
+++ b/solutions/trustclaw/turbo.json
@@ -0,0 +1,9 @@
+{
+ "$schema": "https://turborepo.com/schema.json",
+ "pipeline": {
+ "build": {
+ "outputs": [".next/**", "!.next/cache/**"]
+ },
+ "lint": {}
+ }
+}
diff --git a/solutions/trustclaw/vercel.json b/solutions/trustclaw/vercel.json
new file mode 100644
index 0000000000..0f5e021b3e
--- /dev/null
+++ b/solutions/trustclaw/vercel.json
@@ -0,0 +1,8 @@
+{
+ "crons": [
+ {
+ "path": "/api/cron/trustclaw",
+ "schedule": "0 0 * * *"
+ }
+ ]
+}
|