agents
reference
ai-dev

Cedar-OS + Mastra Integration Guide

10/13/2025

Cedar-OS + Mastra Integration Guide

A complete reference for building agentic applications with Cedar-OS (frontend state management) and Mastra (backend agent orchestration). This guide shows you how to wire backend AI agents to frontend UI updates with zero boilerplate.

The Core Pattern

Cedar-OS enables backend tools to auto-update frontend state through structured responses. Instead of manually orchestrating UI updates in agent code, your Mastra tools return a special object and Cedar handles the rest.

// Backend tool returns this
{
  type: 'setState' as const,
  stateKey: 'currentFile',
  setterKey: 'showFile',
  args: { path: '/src/app.tsx', content: '...' },
  _raw: { success: true }
}

// Cedar automatically calls the registered state setter
// UI updates immediately—no additional agent logic needed

Request flow:

  1. User sends message via Cedar chat interface
  2. Cedar forwards to Mastra backend agent
  3. Agent executes tools
  4. Tools return Cedar-structured responses
  5. Cedar intercepts and updates frontend state
  6. UI re-renders with new state

Frontend: State Management

Pattern 1: Custom State Setters with useRegisterState

Use this when you need custom logic for state updates (validation, transformations, side effects).

import { useRegisterState } from 'cedar-os';
import React from 'react';
import { z } from 'zod';

// 1. Define state with React.useState (NOT useCedarState)
const [files, setFiles] = React.useState<FileNode[]>([]);

// 2. Register state with Cedar and define custom setters
useRegisterState({
  key: 'files',
  description: 'All files in the current sandbox',
  value: files,
  setValue: setFiles,
  stateSetters: {
    updateFileTree: {
      name: 'updateFileTree',
      description: 'Update the file tree structure from sandbox',
      argsSchema: z.object({
        files: z.array(z.any()).describe('Array of file nodes'),
      }),
      execute: (currentState, setValue, args: { files: FileNode[] }) => {
        console.log('[STATE SETTER] updateFileTree called with:', args.files);
        setValue(args.files);
      },
    },
  },
});

Critical rules:

  • Use React.useState + useRegisterState for states with custom setters
  • Use useCedarState ONLY for simple states without custom setters
  • Never use both for the same state—it causes conflicts
  • State key must be unique and match backend tool responses
  • Setter key must match the setterKey in backend tool responses

Pattern 2: Frontend Tools with useRegisterFrontendTool

Use this sparingly—when the agent needs to explicitly call a UI manipulation tool. Prefer state setters for most cases.

import { useRegisterFrontendTool } from 'cedar-os';
import { z } from 'zod';

useRegisterFrontendTool({
  name: 'show-file-content',
  description: 'Display the content of a file in the code preview',
  argsSchema: z.object({
    path: z.string().describe('The path of the file'),
    content: z.string().describe('The content of the file'),
    language: z.string().optional().describe('Programming language'),
  }),
  execute: async (args) => {
    console.log('[FRONTEND TOOL] show-file-content called with:', args);
    const file: CodeFile = {
      path: args.path,
      content: args.content,
      language: args.language || 'text',
      lastModified: new Date().toISOString(),
    };
    setCurrentFile(file);
    setViewMode('preview');
  },
});

When to choose:

  • State setters: Backend tools auto-update UI (preferred for most cases)
  • Frontend tools: Agent explicitly calls tool to update UI (use sparingly)

Pattern 3: Subscribe State to Backend with useSubscribeStateToAgentContext

Send frontend state to the backend agent for context awareness.

import { useSubscribeStateToAgentContext } from 'cedar-os';

useSubscribeStateToAgentContext(
  'sandbox',  // Must match a registered state key
  (sandbox: SandboxInfo | null) => ({
    'sandbox-status': sandbox ? {
      id: sandbox.id,
      status: sandbox.status,
      url: sandbox.url,
    } : null,
  }),
  {
    showInChat: true,
    color: '#8B5CF6',
  }
);

Rules:

  • First parameter must match an existing useRegisterState key
  • Transform function should return serializable data
  • Avoid reactive dependencies (like files.length) inside transform function

Frontend: Message Renderers

Custom renderers display Mastra streaming events (tool calls, results, etc.) in the chat UI.

Location: src/cedar/messageRenderers.tsx

import { createMessageRenderer, CustomMastraMessage } from 'cedar-os';

type ToolCallMessage = CustomMastraMessage<'tool-call'>;

export const ToolCallRenderer = createMessageRenderer<ToolCallMessage>({
  type: 'tool-call',
  namespace: 'mastra',
  render: (message) => {
    const toolName = message.payload?.toolName || 'Unknown Tool';
    return (
      <div className="flex items-center gap-2 p-2 text-sm text-blue-600">
        <span>🔧</span>
        <span>Calling tool: <span className="font-semibold">{toolName}</span></span>
      </div>
    );
  },
  validateMessage: (msg): msg is ToolCallMessage => {
    return msg.type === 'tool-call' && 'payload' in msg;
  },
});

// Export all renderers as array
export const messageRenderers = [ToolCallRenderer, ToolResultRenderer];

Register in layout:

// src/app/layout.tsx
import { messageRenderers } from '@/cedar/messageRenderers';

<CedarCopilot
  messageRenderers={messageRenderers}
  // ... other props
>

Rules:

  • Use CustomMastraMessage<T> for Mastra streaming events
  • Use CustomMessage<T, P> for custom app-specific messages
  • Set namespace: 'mastra' when overriding Mastra's default renderers
  • Always include validateMessage for proper type narrowing
  • Export as array from messageRenderers.tsx

Backend: Cedar-Compatible Tools

Creating Tools That Auto-Update UI

Location: src/backend/src/mastra/tools/*.ts

import { createTool } from '@mastra/core/tools';
import z from 'zod';

export const writeFile = createTool({
  id: 'writeFile',
  description: 'Write a file to the sandbox',
  inputSchema: z.object({
    sandboxId: z.string().describe('The sandboxId'),
    path: z.string().describe('File path'),
    content: z.string().describe('File content'),
  }),
  outputSchema: z
    .object({
      success: z.boolean(),
      path: z.string(),
    })
    .or(z.object({ error: z.string() }))
    .or(
      z.object({
        type: z.literal('setState'),
        stateKey: z.string(),
        setterKey: z.string(),
        args: z.any(),
        _raw: z.any(),
      }),
    ),
  execute: async ({ context }) => {
    try {
      // Do the actual work
      await sandbox.files.write(context.path, context.content);

      // Return Cedar-structured response
      return {
        type: 'setState' as const,  // CRITICAL: Use 'as const'
        stateKey: 'currentFile',     // Must match frontend state key
        setterKey: 'showFile',       // Must match state setter name
        args: {                      // Arguments for the setter
          path: context.path,
          content: context.content,
        },
        _raw: {                      // Original response for backward compatibility
          success: true,
          path: context.path,
        },
      };
    } catch (e) {
      return { error: JSON.stringify(e) };
    }
  },
});

Critical rules:

  • Always use type: 'setState' as const (literal type required)
  • stateKey must exactly match frontend useRegisterState key
  • setterKey must exactly match the state setter name
  • Include _raw for backward compatibility and debugging
  • Add Cedar response type to outputSchema union
  • Return error object on failure: { error: string }

When to Use Cedar Responses

Use Cedar responses when:

  • ✅ Tool should auto-update UI (read/write files, build file tree)
  • ✅ Tool creates/updates resources that have UI representation
  • ✅ Want seamless UI updates without agent orchestration
  • ✅ Tool result directly maps to a state update

Don't use Cedar responses when:

  • ❌ Tool is purely computational (no UI impact)
  • ❌ Agent needs to interpret result before UI update
  • ❌ Tool is a query that agent will process further
  • ❌ Tool has side effects agent needs to coordinate

Common Cedar Tool Patterns

Pattern A: Auto-Display File Content

return {
  type: 'setState' as const,
  stateKey: 'currentFile',
  setterKey: 'showFile',
  args: {
    path: filePath,
    content: fileContent,
  },
  _raw: { success: true, path: filePath },
};

Pattern B: Auto-Update File Tree

return {
  type: 'setState' as const,
  stateKey: 'files',
  setterKey: 'updateFileTree',
  args: {
    files: fileTreeArray,
  },
  _raw: { files: fileTreeArray, rootPath: '/home/user' },
};

Pattern C: Auto-Update Sandbox Info with URL

return {
  type: 'setState' as const,
  stateKey: 'sandbox',
  setterKey: 'updateSandbox',
  args: {
    id: sandboxId,
    status: 'active' as const,
    port: 3000,
    url: `https://${host}`,
    startedAt: new Date().toISOString(),
  },
  _raw: { sandboxId, port: 3000 },
};

Common Mistakes

❌ Mistake 1: Using Both useCedarState and useRegisterState

// WRONG - causes conflicts
const [files, setFiles] = useCedarState({ key: 'files', initialValue: [] });
useRegisterState({ key: 'files', value: files, setValue: setFiles, ... });

✅ Solution:

// RIGHT - use one or the other
const [files, setFiles] = React.useState<FileNode[]>([]);
useRegisterState({ key: 'files', value: files, setValue: setFiles, ... });

❌ Mistake 2: Missing 'as const' on setState Type

// WRONG - type inference fails
return {
  type: 'setState',  // Inferred as string
  stateKey: 'files',
  ...
};

✅ Solution:

// RIGHT
return {
  type: 'setState' as const,  // Literal type
  stateKey: 'files',
  ...
};

❌ Mistake 3: Mismatched Keys

// Frontend
useRegisterState({ key: 'currentFile', stateSetters: { showFile: ... } });

// Backend - WRONG keys
return {
  type: 'setState' as const,
  stateKey: 'file',        // Should be 'currentFile'
  setterKey: 'displayFile', // Should be 'showFile'
  ...
};

✅ Solution:

// Backend - matching keys
return {
  type: 'setState' as const,
  stateKey: 'currentFile',  // Exact match
  setterKey: 'showFile',    // Exact match
  ...
};

❌ Mistake 4: Duplicate Frontend Tools and State Setters

// WRONG - duplication causes confusion
useRegisterState({
  key: 'files',
  stateSetters: { updateFileTree: ... }
});

useRegisterFrontendTool({
  name: 'update-file-tree',  // Duplicates state setter
  execute: async (args) => setFiles(args.files)
});

✅ Solution: Choose one approach (prefer state setters).

❌ Mistake 5: Wrong Message Type for Mastra Events

// WRONG - custom message type
type ToolCallMessage = CustomMessage<'tool-call', { payload: any }>;

✅ Solution:

// RIGHT - Mastra message type
type ToolCallMessage = CustomMastraMessage<'tool-call'>;

Debugging

Frontend Not Updating After Tool Call

  1. Check console logs:

    • [STATE SETTER] prefix = state setter was called
    • [FRONTEND TOOL] prefix = frontend tool was called
    • Neither = Cedar didn't recognize response
  2. Verify state registration:

    // Must have matching key
    useRegisterState({ key: 'currentFile', ... })
  3. Verify backend response structure:

    // Check debugger for tool result
    {
      type: 'setState',  // Must be present
      stateKey: 'currentFile',  // Must match frontend
      setterKey: 'showFile',    // Must match frontend
      args: { ... }
    }
  4. Check for errors:

    • Open Cedar debugger (DebuggerPanel component)
    • Look for "fallback:unhandled" or "unknown" messages
    • Check browser console for Cedar errors
  5. Verify outputSchema includes Cedar response:

    outputSchema: z.object({ ... })
      .or(z.object({ error: z.string() }))
      .or(z.object({
        type: z.literal('setState'),
        stateKey: z.string(),
        setterKey: z.string(),
        args: z.any(),
        _raw: z.any(),
      }))

Message Rendering Issues

  1. Check message type:

    • Open Cedar debugger → Response Handlers
    • Look for "fallback:unhandled" or "unknown:*" tags
  2. Verify message renderer:

    // Must use CustomMastraMessage for Mastra events
    type ToolCallMessage = CustomMastraMessage<'tool-call'>;
    
    // Must set namespace for overrides
    createMessageRenderer<ToolCallMessage>({
      type: 'tool-call',
      namespace: 'mastra',  // Required for override
      ...
    })
  3. Verify registration:

    // layout.tsx
    import { messageRenderers } from '@/cedar/messageRenderers';
    
    <CedarCopilot messageRenderers={messageRenderers} ... />

File Organization

src/
├── app/
│   ├── layout.tsx              # Register messageRenderers here
│   └── page.tsx                # Define states and tools here
├── cedar/
│   ├── messageRenderers.tsx    # Custom message renderers
│   └── components/
│       └── text/
│           └── ShimmerText.tsx # Reusable UI components
├── backend/
│   └── src/
│       └── mastra/
│           ├── agents/
│           │   └── coding-agent.ts  # Agent configuration
│           └── tools/
│               └── e2b.ts           # Tool definitions with Cedar responses
└── types/
    └── *.ts                    # Shared TypeScript types

Best Practices

Frontend

  1. Use React.useState + useRegisterState for states with custom setters
  2. Use useCedarState ONLY for simple states without setters
  3. Never duplicate state management approaches
  4. Keep state keys unique and descriptive
  5. Add console logs during development for debugging

Backend

  1. Return Cedar-structured responses for UI-impacting tools
  2. Always use type: 'setState' as const
  3. Match stateKey and setterKey exactly to frontend
  4. Include _raw field for backward compatibility
  5. Add Cedar response type to outputSchema union
  6. Return { error: string } on failures

Message Rendering

  1. Use CustomMastraMessage<T> for Mastra events
  2. Use CustomMessage<T, P> for custom messages
  3. Set namespace: 'mastra' when overriding defaults
  4. Always include validateMessage function
  5. Register all renderers in layout.tsx

Debugging

  1. Use console logs with prefixes: [STATE SETTER], [FRONTEND TOOL]
  2. Use Cedar debugger panel for response inspection
  3. Check browser console for Cedar errors
  4. Verify state keys match exactly between frontend/backend
  5. Test Cedar responses in isolation before full integration

Quick Reference

Frontend State Declaration

const [state, setState] = React.useState<Type>(initialValue);
useRegisterState({
  key: 'stateKey',
  description: 'Description',
  value: state,
  setValue: setState,
  stateSetters: {
    setterName: {
      name: 'setterName',
      description: 'Setter description',
      argsSchema: z.object({ ... }),
      execute: (currentState, setValue, args) => {
        console.log('[STATE SETTER] setterName called with:', args);
        setValue(args.newValue);
      },
    },
  },
});

Backend Cedar Response

return {
  type: 'setState' as const,
  stateKey: 'stateKey',      // Match frontend
  setterKey: 'setterName',   // Match frontend
  args: {
    // Arguments matching setter's argsSchema
  },
  _raw: {
    // Original response data
  },
};

Message Renderer

import { createMessageRenderer, CustomMastraMessage } from 'cedar-os';

type MyMessage = CustomMastraMessage<'my-type'>;

export const MyRenderer = createMessageRenderer<MyMessage>({
  type: 'my-type',
  namespace: 'mastra',
  render: (message) => <div>{message.payload?.content}</div>,
  validateMessage: (msg): msg is MyMessage => {
    return msg.type === 'my-type' && 'payload' in msg;
  },
});

Additional Resources


This integration pattern eliminates boilerplate state synchronization between AI backends and frontend UIs. Backend tools declare their UI impact through structured responses, and Cedar handles the rest—no manual event buses, no scattered state logic, no agent orchestration overhead.