Skip to main content
Every Synthetiq app can include an in-app AI agent that understands and can interact with the app’s API surface, data model, and connected services. The agent always assumes the scopes and permissions of the user using it, as defined in scopes.json (see Add access control). The framework handles the agent loop, tool execution, chat history, context compaction, streaming, and scope enforcement. It auto-generates system prompts from the app’s procedures, database schema, and connected services. You simply provide the LLM integration and customize the agent’s instructions.

LLM integration

The AI agent needs an LLM service. Install the Anthropic service client:
pnpm add @synthetiq/services-anthropic-client
Configure it in src/server/lib/agentLLM.ts (this file is scaffolded when you create the app):
import LLMClient from "@synthetiq/services-anthropic-client";
import { throwIfChunkError } from "@synthetiq/app-framework/server";

export async function* streamLLM(
  messages: Array<{ role: 'user' | 'assistant'; content: string }>,
  systemPrompt: string,
): AsyncGenerator<string> {
  const client = new LLMClient();

  for await (const chunk of client.streamMessageStream({
    model: "claude-sonnet-4-20250514",
    max_tokens: 8192,
    system: [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }],
    messages,
  })) {
    throwIfChunkError(chunk);
    yield chunk;
  }
}
The client handles authentication automatically using platform-managed credentials — app code never sees API keys.

System prompt

Customize the agent’s personality for the task tracker in src/server/lib/appSystemPrompt.ts:
export const APP_SYSTEM_PROMPT = `You are a task management assistant that helps users
track tasks, plan work, and stay organized.

You can:
- Create and update tasks
- List tasks filtered by status or priority
- Help users plan their work and prioritize tasks

Always be concise and action-oriented.`;
The framework automatically appends the API manifest, database schema, and service metadata to this prompt — so the agent knows about every procedure, table, and service tool without you listing them manually.

Scope-aware tool access

The agent only sees and can access procedures that the authenticated user has permission to call. A user with the tasks:viewAll scope sees the getAllTasks tool; a regular user only sees getMyTasks. This filtering happens automatically based on the user’s roles. Additionally, you can give the AI agent direct access to service tools. For example, to let admins with the slack:post scope ask the agent to post Slack messages, add the scope and update the services section of scopes.json:
{
  "scopes": [
    ...
    { "name": "slack:post", "description": "Post messages to Slack via the AI agent" }
  ],
  "tables": { ... },
  "services": {
    "slack": {
      "requiredScopes": ["slack:post"],
      "tools": {}
    }
  }
}
The requiredScopes array gates access to the entire Slack service — only users with the slack:post scope can use any Slack tools through the AI agent. You can also gate specific tools individually via the tools object. For more details, see the Access control reference.

Chat UI

The framework provides all the components needed to build an in-app AI chat. Here’s an example:
// src/web/pages/Chat.tsx
import { useMemo } from "react";
import {
  useAgentChat, ChatMessageComponent as ChatMessage,
  ChatInput, ToolCallsPanel, MessageType,
} from "@synthetiq/app-framework/web";
import type { ChatMessageData as ChatMessageType } from "@synthetiq/app-framework/web";

export function ChatPage() {
  const {
    sessions, currentSession, messages,
    isProcessing, processingStage, isLoading,
    sendMessage, cancelRequest, selectSession,
  } = useAgentChat({});

  const groupedMessages = useMemo(() => {
    type MessageGroup =
      | { type: "user"; message: ChatMessageType }
      | { type: "tools"; messages: ChatMessageType[] }
      | { type: "assistant"; message: ChatMessageType };

    const groups: MessageGroup[] = [];
    let currentToolGroup: ChatMessageType[] = [];

    for (const msg of messages) {
      if (msg.type === MessageType.USER_MESSAGE) {
        if (currentToolGroup.length > 0) {
          groups.push({ type: "tools", messages: currentToolGroup });
          currentToolGroup = [];
        }
        groups.push({ type: "user", message: msg });
      } else if (msg.type === MessageType.ASSISTANT_MESSAGE) {
        if (currentToolGroup.length > 0) {
          groups.push({ type: "tools", messages: currentToolGroup });
          currentToolGroup = [];
        }
        groups.push({ type: "assistant", message: msg });
      } else {
        currentToolGroup.push(msg);
      }
    }
    if (currentToolGroup.length > 0) {
      groups.push({ type: "tools", messages: currentToolGroup });
    }
    return groups;
  }, [messages]);

  const handleSend = async (content: string) => {
    if (currentSession) {
      await sendMessage(content, currentSession.id);
    } else {
      await sendMessage(content, crypto.randomUUID(), true);
    }
  };

  return (
    <div className="flex flex-col h-full">
      <div className="flex items-center gap-2 p-2 border-b">
        <select
          value={currentSession?.id || ""}
          onChange={(e) => e.target.value && selectSession(e.target.value)}
        >
          <option value="" disabled>Select chat...</option>
          {sessions.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
        </select>
        <button onClick={() => selectSession("")}>New Chat</button>
      </div>
      <div className="flex-1 overflow-y-auto p-4 pb-24 space-y-4">
        {groupedMessages.map((group, i) => {
          const isLastGroup = i === groupedMessages.length - 1;
          if (group.type === "user") {
            return (
              <div key={`user-${i}`}>
                <ChatMessage message={group.message} />
                {isProcessing && isLastGroup && (
                  <ToolCallsPanel messages={[]} isStreaming={true} processingStage={processingStage} />
                )}
              </div>
            );
          }
          if (group.type === "tools") {
            return (
              <ToolCallsPanel
                key={`tools-${i}`}
                messages={group.messages}
                isStreaming={isProcessing && isLastGroup}
                processingStage={isLastGroup ? processingStage : null}
              />
            );
          }
          if (group.type === "assistant") {
            return <ChatMessage key={`assistant-${i}`} message={group.message} />;
          }
          return null;
        })}
      </div>
      <ChatInput
        onSend={handleSend}
        disabled={isProcessing || isLoading}
        isProcessing={isProcessing}
        onCancel={cancelRequest}
      />
    </div>
  );
}
The useAgentChat hook manages sessions, message history, streaming, and state. The framework provides ChatMessageComponent, ChatInput, ToolCallsPanel, and ProcessingStage — you arrange and style them as needed.

Capabilities

With the task tracker’s procedures, database schema, and connected services, users can now ask the agent things like:
  • “What tasks do I have this week?”
  • “Create a high-priority task called ‘Update API docs’”
  • “Mark the ‘Fix login bug’ task as completed”
  • “How many active tasks do I have?”
Admins with additional scopes can also ask:
  • “Show me all tasks across the team”
  • “Who has the most overdue tasks?”
  • “Post a summary of today’s completed tasks to Slack” (requires slack:post scope)