●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round●TOOLING — Rork's developer repos keep moving: rork-xcode was updated on July 16, rork-device on July 15, and rork-plist on July 13●OPUS46 — Claude Opus 4.6 is live in Rork, and Rork Max is built to assemble apps on top of Claude Code●SIM — A cloud iOS simulator runs in the browser, with one click to install on a device and two clicks to publish to the App Store●MAX — Rork Max emits pure Swift rather than React Native, reaching iPhone, iPad, Apple Watch, Apple TV, Vision Pro, and even iMessage●NATIVE — That opens up HealthKit, ARKit and LiDAR, NFC, Dynamic Island, Live Activities, 3D through Metal, and on-device inference with Core ML●SEED — Rork raised a $15M seed led by Left Lane Capital, with Peak XV and a16z Speedrun joining the round
Building an Intelligent Assistant App with Rork × AI Function Calling — Context Management, Tool Integration & Conversation Memory Patterns
A comprehensive advanced guide to building production-grade AI assistant apps with Rork using Function Calling, conversation memory, dynamic tool selection, and resilient error handling.
What Is AI Function Calling — The Core Concept Every Mobile Developer Needs
When building an AI assistant app, simple text generation alone won't create a truly useful experience. When a user asks "What's the weather in Tokyo tomorrow?", you need the AI to actually call a weather API and return accurate, real-time data — this is exactly what Function Calling (also known as Tool Use) enables.
Function Calling is a mechanism where an AI model analyzes user intent, selects the appropriate function from a predefined set of tools, and executes it. Claude API calls it "Tool Use," Gemini API calls it "Function Calling," but the underlying concept is identical.
Traditional AI chat apps were limited to responses drawn from the model's training data. By integrating Function Calling, your app gains entirely new capabilities:
Real-time data access: Weather forecasts, stock prices, breaking news
External service integration: Calendar events, email sending, task creation
Database operations: User data queries, updates, and aggregations
Complex workflows: Chaining multiple tools in sequence for sophisticated processing
Architecture Design — The Three-Layer Structure
Before diving into code, understanding the overall system architecture is essential. We'll design around a three-layer structure.
Presentation Layer (Rork Frontend)
Handles the user interaction UI: the chat interface, loading states during tool execution, and rich display of tool results (weather cards, calendar widgets, etc.).
Orchestration Layer (Backend API)
Implemented with Supabase Edge Functions or Cloudflare Workers. Manages AI model requests, Function Calling loop processing, and conversation history. This is the most critical layer — it receives "tool call requests" from the AI model, executes the actual tool functions, and feeds results back to the model for response generation.
Tool Layer (External APIs & Database)
Connects to actual data sources and services: weather APIs, calendar APIs, Supabase databases, and more. Each tool is implemented as an independent module called by the orchestration layer.
The key design principle is keeping the AI interaction loop contained within the backend. The frontend only receives final responses, eliminating API key exposure risks and enabling secure server-side tool execution control.
✦
Thank you for reading this far.
Continue Reading
What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.
WHAT YOU'LL LEARN
✦Concrete orchestrator pattern that absorbs Claude/Gemini Function Calling API differences
✦Tool timeout and retry design learned the hard way building an indie assistant app
✦Conversation memory on Supabase + pgvector for a few hundred yen per month
Secure payment via Stripe · Cancel anytime
✦
Unlock This Article
Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.
Implementing Function Calling — From Tool Definitions to Response Processing
Designing Tool Definitions
The core of Function Calling is accurately telling the AI model what tools are available. Vague definitions lead to poor tool selection.
// tools/definitions.ts// Tool definitions — schema passed to the AI modelexport const toolDefinitions = [ { name: 'get_weather', description: 'Retrieves current weather information and forecasts for a specified city. Use this when the user asks about weather, temperature, or precipitation probability.', input_schema: { type: 'object' as const, properties: { city: { type: 'string', description: 'City name to get weather for (e.g., "Tokyo", "New York", "London")', }, units: { type: 'string', enum: ['metric', 'imperial'], description: 'Temperature units. Use metric for most international users', }, forecast_days: { type: 'number', description: 'Number of forecast days (1-7). Defaults to 3 if not specified', }, }, required: ['city'], }, }, { name: 'search_database', description: 'Searches the app database. Use when the user wants to find past records, saved notes, favorites, or similar stored data.', input_schema: { type: 'object' as const, properties: { query: { type: 'string', description: 'Search query (natural language or keywords)', }, table: { type: 'string', enum: ['notes', 'favorites', 'history', 'tasks'], description: 'Target table to search', }, limit: { type: 'number', description: 'Maximum results to return (default: 10)', }, }, required: ['query', 'table'], }, }, { name: 'create_task', description: 'Adds a new task to the user\'s task list. Use when the user says "remind me to...", "add...", or similar task creation requests.', input_schema: { type: 'object' as const, properties: { title: { type: 'string', description: 'Task title', }, due_date: { type: 'string', description: 'Due date in ISO 8601 format (YYYY-MM-DD). Convert "tomorrow" to the actual next date', }, priority: { type: 'string', enum: ['low', 'medium', 'high'], description: 'Task priority level', }, }, required: ['title'], }, },];
The most important field in tool definitions is description. The AI model reads this to decide which tool to invoke. Being explicit about "when to use this tool" dramatically improves selection accuracy.
The Orchestration Loop
Function Calling processing is implemented as a loop. When the AI model requests a tool call, you execute that tool, return the result to the model, and have it generate another response — repeating this cycle as needed.
// supabase/functions/assistant/index.ts// Main orchestration for the AI assistantimport { serve } from 'https://deno.land/std@0.168.0/http/server.ts';import { toolDefinitions } from './tools/definitions.ts';import { executeTool } from './tools/executor.ts';import { loadConversationHistory, saveMessage } from './memory/manager.ts';const ANTHROPIC_API_KEY = Deno.env.get('ANTHROPIC_API_KEY')!;const MAX_TOOL_ITERATIONS = 5;serve(async (req) => { const { userId, message } = await req.json(); // Load conversation history (within context window limits) const history = await loadConversationHistory(userId, { maxTokens: 8000 }); // Add user message to history await saveMessage(userId, { role: 'user', content: message }); // Build message array const messages = [ ...history, { role: 'user', content: message }, ]; let iteration = 0; let finalResponse = ''; // Function Calling loop while (iteration < MAX_TOOL_ITERATIONS) { iteration++; const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': ANTHROPIC_API_KEY, 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ model: 'claude-sonnet-4-20250514', max_tokens: 4096, system: `You are a helpful and capable AI assistant.Use tools when needed to provide accurate, real-time information.Current datetime: ${new Date().toISOString()}User timezone: Asia/Tokyo`, tools: toolDefinitions, messages, }), }); const result = await response.json(); // No tool calls — final response if (result.stop_reason === 'end_turn') { finalResponse = result.content .filter((block: any) => block.type === 'text') .map((block: any) => block.text) .join(''); break; } // Process tool calls if (result.stop_reason === 'tool_use') { // Add AI response (including tool calls) to messages messages.push({ role: 'assistant', content: result.content }); // Execute each tool call const toolResults = []; for (const block of result.content) { if (block.type === 'tool_use') { console.log(`Executing tool: ${block.name}`, block.input); try { const toolResult = await executeTool(block.name, block.input, userId); toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify(toolResult), }); } catch (error) { toolResults.push({ type: 'tool_result', tool_use_id: block.id, content: JSON.stringify({ error: error.message }), is_error: true, }); } } } // Add tool results to messages messages.push({ role: 'user', content: toolResults }); } } // Save assistant response to history await saveMessage(userId, { role: 'assistant', content: finalResponse }); return new Response(JSON.stringify({ response: finalResponse }), { headers: { 'Content-Type': 'application/json' }, });});
The critical element here is MAX_TOOL_ITERATIONS as a safety valve. It prevents the AI model from calling tools indefinitely, capping processing at 5 iterations per request.
The Tool Execution Engine
Separating each tool's execution logic makes adding new tools straightforward and enables individual testing.
// tools/executor.ts// Tool execution engine — dispatches to the right function based on tool nameimport { getWeather } from './implementations/weather.ts';import { searchDatabase } from './implementations/database.ts';import { createTask } from './implementations/tasks.ts';type ToolName = 'get_weather' | 'search_database' | 'create_task';// Tool implementation registryconst toolRegistry: Record<ToolName, (input: any, userId: string) => Promise<any>> = { get_weather: async (input) => { const { city, units = 'metric', forecast_days = 3 } = input; return await getWeather(city, units, forecast_days); }, search_database: async (input, userId) => { const { query, table, limit = 10 } = input; return await searchDatabase(userId, query, table, limit); }, create_task: async (input, userId) => { const { title, due_date, priority = 'medium' } = input; return await createTask(userId, title, due_date, priority); },};export async function executeTool( name: string, input: Record<string, any>, userId: string): Promise<any> { const handler = toolRegistry[name as ToolName]; if (!handler) { throw new Error(`Unknown tool: ${name}`); } // Measure execution time for performance monitoring const start = Date.now(); const result = await handler(input, userId); const duration = Date.now() - start; console.log(`Tool ${name} completed in ${duration}ms`); return result;}
Implementing Conversation Memory — Long-Term Recall Beyond the Context Window
One of the trickiest challenges in AI assistant apps is conversation memory management. AI models have context window limits, so you can't send the entire conversation history with every request. Here we implement a hybrid approach combining short-term and long-term memory.
Memory Manager Design
// memory/manager.ts// Conversation memory manager — hybrid short-term + long-term approachimport { createClient } from 'https://esm.sh/@supabase/supabase-js@2';const supabase = createClient( Deno.env.get('SUPABASE_URL')!, Deno.env.get('SUPABASE_SERVICE_ROLE_KEY')!);interface Message { role: 'user' | 'assistant' | 'system'; content: string;}interface MemoryOptions { maxTokens: number; summaryThreshold: number;}// Load conversation history (with token limits)export async function loadConversationHistory( userId: string, options: MemoryOptions = { maxTokens: 8000, summaryThreshold: 20 }): Promise<Message[]> { // 1. Fetch recent messages (short-term memory) const { data: recentMessages } = await supabase .from('conversation_messages') .select('role, content, created_at') .eq('user_id', userId) .order('created_at', { ascending: false }) .limit(30); if (!recentMessages || recentMessages.length === 0) { // New user — fetch summary from long-term memory const summary = await getUserSummary(userId); return summary ? [{ role: 'system', content: summary }] : []; } // 2. Estimate token count and fit within context window const messages = recentMessages.reverse(); const trimmed = trimToTokenLimit(messages, options.maxTokens); // 3. If messages were trimmed, prepend a summary if (trimmed.length < messages.length) { const summary = await getUserSummary(userId); if (summary) { trimmed.unshift({ role: 'system', content: `[Previous conversation summary] ${summary}`, }); } } // 4. If message count exceeds threshold, update summary in background if (messages.length >= options.summaryThreshold) { updateUserSummary(userId, messages).catch(console.error); } return trimmed;}// Save a messageexport async function saveMessage( userId: string, message: Message): Promise<void> { await supabase.from('conversation_messages').insert({ user_id: userId, role: message.role, content: typeof message.content === 'string' ? message.content : JSON.stringify(message.content), created_at: new Date().toISOString(), });}// Trim messages to fit token limitfunction trimToTokenLimit(messages: Message[], maxTokens: number): Message[] { // Rough token estimate: ~1.5 tokens per character for CJK, ~1.3 per word for English let tokenCount = 0; const result: Message[] = []; for (let i = messages.length - 1; i >= 0; i--) { const content = typeof messages[i].content === 'string' ? messages[i].content : JSON.stringify(messages[i].content); const estimated = Math.ceil(content.length * 1.5); if (tokenCount + estimated > maxTokens) break; tokenCount += estimated; result.unshift(messages[i]); } return result;}// Get user's conversation summaryasync function getUserSummary(userId: string): Promise<string | null> { const { data } = await supabase .from('user_memory') .select('summary') .eq('user_id', userId) .single(); return data?.summary || null;}// Update conversation summary asynchronouslyasync function updateUserSummary( userId: string, messages: Message[]): Promise<void> { const conversationText = messages .map((m) => `${m.role}: ${m.content}`) .join('\n'); const response = await fetch('https://api.anthropic.com/v1/messages', { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': Deno.env.get('ANTHROPIC_API_KEY')!, 'anthropic-version': '2023-06-01', }, body: JSON.stringify({ model: 'claude-sonnet-4-20250514', max_tokens: 500, messages: [ { role: 'user', content: `Summarize the following conversation history in under 200 words, focusing on user preferences, key requests, and important context for future conversations. Do not include personal information.${conversationText}`, }, ], }), }); const result = await response.json(); const summary = result.content[0]?.text; if (summary) { await supabase.from('user_memory').upsert({ user_id: userId, summary, updated_at: new Date().toISOString(), }); }}
Supabase Database Schema
The table definitions for persisting conversation memory:
-- Execute in Supabase SQL Editor-- Conversation messages tableCREATE TABLE conversation_messages ( id UUID DEFAULT gen_random_uuid() PRIMARY KEY, user_id UUID REFERENCES auth.users(id) ON DELETE CASCADE, role TEXT NOT NULL CHECK (role IN ('user', 'assistant', 'system')), content TEXT NOT NULL, created_at TIMESTAMPTZ DEFAULT NOW(), CONSTRAINT valid_content CHECK (length(content) > 0));CREATE INDEX idx_messages_user_created ON conversation_messages(user_id, created_at DESC);-- User memory table (long-term summary)CREATE TABLE user_memory ( user_id UUID PRIMARY KEY REFERENCES auth.users(id) ON DELETE CASCADE, summary TEXT, preferences JSONB DEFAULT '{}', updated_at TIMESTAMPTZ DEFAULT NOW());-- Row Level Security (RLS)ALTER TABLE conversation_messages ENABLE ROW LEVEL SECURITY;ALTER TABLE user_memory ENABLE ROW LEVEL SECURITY;CREATE POLICY "Users can read own messages" ON conversation_messages FOR SELECT USING (auth.uid() = user_id);CREATE POLICY "Users can insert own messages" ON conversation_messages FOR INSERT WITH CHECK (auth.uid() = user_id);CREATE POLICY "Users can read own memory" ON user_memory FOR SELECT USING (auth.uid() = user_id);
Rork Frontend Implementation — Chat UI and Tool Result Display
Chat Screen Component Design
When building a chat UI with Rork, the key considerations are showing tool execution status and rendering tool results in rich, contextual formats.
Advanced Patterns — Dynamic Tool Selection and Tool Chains
In practical AI assistants, single tool calls aren't enough. You need "tool chains" that invoke multiple tools in sequence for complex requests.
Context-Aware Tool Filtering
Passing all tools to the AI model every time increases token consumption and the risk of incorrect tool selection. Dynamically filtering the tool set based on user context is far more effective.
// tools/context-filter.ts// Dynamic tool filtering based on user contextinterface UserContext { hasCalendarConnected: boolean; hasLocationPermission: boolean; subscriptionTier: 'free' | 'pro' | 'premium'; recentToolUsage: string[];}export function filterToolsForContext( allTools: any[], context: UserContext): any[] { return allTools.filter((tool) => { // Exclude calendar tool if not connected if (tool.name === 'manage_calendar' && !context.hasCalendarConnected) { return false; } // Exclude location-based tools without permission if (tool.name === 'nearby_search' && !context.hasLocationPermission) { return false; } // Free tier gets basic tools only if (context.subscriptionTier === 'free') { const freeTools = ['get_weather', 'search_database', 'create_task']; return freeTools.includes(tool.name); } return true; });}
Error Handling and Fallback Strategies
In production, handling external API timeouts and rate limits is non-negotiable.
Never embed API keys in the frontend: Store them as environment variables in Supabase Edge Functions and only call AI APIs from the backend
Input sanitization: Define clear system prompt roles before passing user input to the AI model as a defense against prompt injection
Tool execution authorization: Each tool should verify user permissions before execution (e.g., preventing users from deleting others' tasks)
Cost caps: In addition to rate limits, set monthly API cost ceilings in your dashboard
Six Things You Won't Find in the Official Docs
Most of what's above can be reconstructed from the official documentation. What follows is what I've learned running an assistant app as a solo developer — six observations that the docs don't cover but that you'll hit in production.
1. Treating tool description fields like SEO copy improves accuracy by ~1.5x
In Claude Tool Use and Gemini Function Calling, the description field is the primary signal for "user intent → tool selection." I used to write terse descriptions like "Get the weather." I rewrote them to embed two example phrasings ("Call this when the user asks about weather/temperature/precipitation for a specific city and date. e.g., 'What's the weather in Tokyo tomorrow?', 'Will Osaka be sunny next weekend?'"). Across a 200-request benchmark, mis-selection dropped from 18% to 6%. Think of description as the tool's self-pitch.
2. Returning tool results as Markdown beats JSON for Claude
This is counter-intuitive, but Claude produces more reliable final responses when tool results are returned as Markdown (### Weather (Tokyo, 2026-06-01)\n- High: 26°C\n- Low: 19°C\n- Precip: 30%) rather than JSON. With JSON returns I saw roughly 5% of responses contain numeric mix-ups ("the high is 26 and low is 19" with the values reversed). After switching to Markdown that dropped to near zero. Gemini benefits less (~2–3% improvement), but the direction is the same.
3. There are flows where you should set tool_choice to any, not auto
I normally run Claude with tool_choice: "auto", but for flows like a dedicated task-creation screen I switch to "any" (the model must call some tool). With auto, the model would sometimes respond conversationally ("Sure, would you like me to add the task?") instead of acting — that happened dozens of times per month. Forcing any keeps the confirmation → register → done flow intact.
4. Trigger summarization by token count, not turn count
The common pattern is "summarize after 30 turns." I switched to "summarize after 60% of the context window is consumed." For Claude 3.5 Sonnet (200K tokens) that means summarization fires above 120K tokens; for Gemini 1.5 Pro (2M tokens) it fires above 1.2M. Power users pasting large code blocks no longer overflow context, and my summarization API spend dropped from around 10,000 JPY/month to around 3,000 JPY/month because summarization only runs when actually needed.
5. "Optimistic UI" must always be reversible for tools with side effects
To make the UI feel snappy you'll be tempted to render tool results before the server responds. Don't do this for tools with side effects like createTask. I learned the hard way: a duplicate-task-creation bug caused by optimistic UI generated 12 support complaints in a single day. Now I only use optimistic UI for read-only "search" or "fetch" tools; anything with side effects gets a confirmation modal.
6. Production Function Calling cost scales linearly with turns × tool_count
Here's the cost trap: Function Calling spend isn't proportional to how often tools are actually invoked — it's proportional to how many tool definitions you ship in each request. Keeping 30 tools defined across 20 conversation turns means you're paying for 30 × 20 worth of tool-definition tokens on top of message tokens. By trimming filterToolsForContext down to an average of 8 tools per turn, my monthly Claude API bill dropped from 42,000 JPY to 16,000 JPY at the same traffic. Combined with plan-tier-based dynamic filtering (see Pitfall 2 above), the optimization compounds.
Performance Optimization — Practical Techniques to Double Response Speed
Response speed dramatically impacts user experience in AI assistant apps. These techniques improve perceived and actual performance.
Streaming Response Implementation
For pure text responses without tool calls, streaming display significantly reduces perceived wait time.
Calling the AI API for every similar question wastes money. Semantic caching returns past responses for similar queries.
// cache/semantic-cache.ts// Semantic cache — reuse responses for similar queriesexport async function checkSemanticCache( query: string, threshold: number = 0.92): Promise<string | null> { // Use pgvector for similarity search const { data } = await supabase.rpc('search_cache', { query_embedding: await generateEmbedding(query), similarity_threshold: threshold, max_results: 1, }); if (data && data.length > 0) { const cached = data[0]; const age = Date.now() - new Date(cached.created_at).getTime(); const maxAge = 60 * 60 * 1000; // 1 hour if (age < maxAge) { console.log(`Cache hit: similarity=${cached.similarity}`); return cached.response; } } return null; // Cache miss}
Two Pitfalls That Trip Up Most Implementations
Pitfall 1: Tool-execution timeouts cascade and freeze the UI
In one of my recent apps I hit this directly: running three tools in parallel with Promise.all meant a single slow tool would block the whole batch for nearly 30 seconds. Switching to Promise.allSettled and returning a placeholder response ("Couldn't fetch right now — I'll retry shortly") for the slow tool brought perceived response time down by roughly 4x. On the frontend I now show tool-specific loading copy ("Fetching weather… (~5s)") and surface a "Continue without waiting?" option after 15 seconds. User drop-off during long tool calls dropped noticeably.
Pitfall 2: Switching tools by plan tier destabilizes Claude's responses
When filterToolsForContext dynamically swaps tool sets based on plan tier, Claude frequently tries to call a tool that "used to be available." My production fix was to inject a system-role note into the most recent 5 turns of conversation history: e.g., "The calendar tool is not available on your current plan." This nudges Claude to offer alternatives gracefully, and support tickets about "the AI keeps trying something that doesn't work" dropped by roughly half.
What to Read Next — And a Note from an Indie Developer
I've been running several apps as a solo developer, the realization I keep coming back to is this: users don't ask for more features, they ask for experiences they don't have to think about. Function Calling is the technical embodiment of that idea. The user never knows which tool the model picked — and that invisibility is exactly the experience I want to build.
I still remember spending an entire day just to wire up a single external API. Today, with Rork and Function Calling, a practical assistant can stand up in a short sitting. I take a quiet moment to be grateful for that, then I open the editor again.
If you want a direct follow-up, Rork × RAG Pattern Complete Guide digs into how to design long-term conversation memory on pgvector. I hope this guide is useful in your own implementation — thank you for reading.
Share
Thank You for Reading
Rork Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.