Skip to content

Frontend Integration

Integrate Next.js, React, and other frontend frameworks with Magic Runtime's controller API. Type-safe client, error handling, and real-time patterns.

API Overview

The Magic Runtime exposes a REST API for executing controllers. All endpoints accept and return JSON.

Method Endpoint Description
POST /api/v1/controllers/execute Execute a controller
GET /api/v1/controllers/ List deployed controllers
GET /api/v1/controllers/executions/{request_id} Get execution result by request ID
GET /api/v1/health/live Liveness check
GET /api/v1/health/ready Readiness check

Request / Response Example

bash
# Execute a controller
curl -X POST https://your-magic-instance/api/v1/controllers/execute \
  -H "Content-Type: application/json" \
  -H "X-API-Key: $MAGIC_API_KEY" \
  -d '{"controller": "InvoiceSync", "customer_id": "cust_123", "invoice_data": {"amount": 9900}}'

# Response
{
  "result": { "sync_id": "inv_456", "status": "completed" },
  "request_id": "req_abc123",
  "duration_ms": 142,
  "controller": "InvoiceSync",
  "version": "1.0.0"
}

Tracing

All responses include request_id for tracing. Pass it in support tickets or log queries.

TypeScript Client

A type-safe client wrapper that handles authentication, error parsing, and response typing.

lib/magic-client.ts
// lib/magic-client.ts

interface MagicResponse<T> {
  result: T;
  request_id: string;
  duration_ms: number;
  controller: string;
  version: string;
}

interface MagicError {
  error: string;
  code: string;       // E-code (e.g., E1001)
  request_id: string;
  detail?: string;
}

// Auth mode: API Key (service-to-service, the only mode currently supported)
// Bearer/JWT auth for controller routes is planned. Currently, use X-API-Key
// for server-side controller execution.
type AuthMode =
  | { mode: "api-key"; key: string }
  | { mode: "bearer"; token: string };  // reserved for future use

class MagicClient {
  private baseUrl: string;
  private auth: AuthMode;

  constructor(baseUrl: string, auth: AuthMode) {
    this.baseUrl = baseUrl;
    this.auth = auth;
  }

  private authHeader(): Record<string, string> {
    return this.auth.mode === "api-key"
      ? { "X-API-Key": this.auth.key }
      : { "Authorization": `Bearer ${this.auth.token}` };
  }

  async execute<TInput, TOutput>(
    controller: string,
    input: TInput
  ): Promise<MagicResponse<TOutput>> {
    const res = await fetch(
      `${this.baseUrl}/api/v1/controllers/execute`,
      {
        method: "POST",
        headers: {
          "Content-Type": "application/json",
          ...this.authHeader(),
        },
        body: JSON.stringify({ controller, ...input as object }),
      }
    );

    if (!res.ok) {
      const err: MagicError = await res.json();
      throw new MagicExecutionError(err);
    }

    return res.json();
  }

  async listControllers(): Promise<string[]> {
    const res = await fetch(`${this.baseUrl}/api/v1/controllers/`, {
      headers: this.authHeader(),
    });
    return res.json();
  }
}

class MagicExecutionError extends Error {
  code: string;
  requestId: string;
  detail?: string;

  constructor(err: MagicError) {
    super(err.error);
    this.name = "MagicExecutionError";
    this.code = err.code;
    this.requestId = err.request_id;
    this.detail = err.detail;
  }
}

// API Key mode (service-to-service, scripts, Server Actions)
export const magic = new MagicClient(
  process.env.NEXT_PUBLIC_MAGIC_URL!,
  { mode: "api-key", key: process.env.MAGIC_API_KEY! }
);

// Bearer mode is NOT yet supported for controller routes.
// For browser-side LLM access, use the LLM Gateway subject-session flow instead.
// See: /llm-gateway.html

Next.js Integration

Use Server Actions to call Magic Runtime from Next.js components while keeping your API token server-side.

Server Action

app/actions/sync-invoice.ts
// app/actions/sync-invoice.ts
"use server";

import { magic } from "@/lib/magic-client";

interface SyncInput {
  customer_id: string;
  invoice_data: { amount: number; currency: string };
}

interface SyncResult {
  sync_id: string;
  status: string;
}

export async function syncInvoice(input: SyncInput) {
  const response = await magic.execute<SyncInput, SyncResult>(
    "InvoiceSync",
    input
  );
  return response.result;
}

API Route

app/api/controllers/[name]/route.ts
// app/api/controllers/[name]/route.ts
import { magic } from "@/lib/magic-client";
import { NextRequest, NextResponse } from "next/server";

export async function POST(
  req: NextRequest,
  { params }: { params: { name: string } }
) {
  const input = await req.json();

  try {
    const result = await magic.execute(params.name, input);
    return NextResponse.json(result);
  } catch (err) {
    if (err instanceof MagicExecutionError) {
      return NextResponse.json(
        { error: err.message, code: err.code },
        { status: 422 }
      );
    }
    return NextResponse.json(
      { error: "Internal error" },
      { status: 500 }
    );
  }
}

Security

Server Actions keep your MAGIC_API_KEY on the server. Never expose it to the browser.

React Hooks

A custom hook for executing controllers from client components, with loading state, error handling, and request tracing.

useMagic Hook

hooks/use-magic.ts
// hooks/use-magic.ts
import { useState, useCallback } from "react";

interface UseMagicOptions {
  onError?: (error: Error) => void;
}

export function useMagic<TInput, TOutput>(
  controller: string,
  options?: UseMagicOptions
) {
  const [data, setData] = useState<TOutput | null>(null);
  const [loading, setLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);
  const [requestId, setRequestId] = useState<string | null>(null);

  const execute = useCallback(
    async (input: TInput) => {
      setLoading(true);
      setError(null);

      try {
        const res = await fetch(`/api/controllers/${controller}`, {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify(input),
        });

        if (!res.ok) {
          const err = await res.json();
          throw new Error(err.error || "Execution failed");
        }

        const response = await res.json();
        setData(response.result);
        setRequestId(response.request_id);
        return response.result;
      } catch (err) {
        const error = err instanceof Error ? err : new Error(String(err));
        setError(error);
        options?.onError?.(error);
        throw error;
      } finally {
        setLoading(false);
      }
    },
    [controller, options]
  );

  return { execute, data, loading, error, requestId };
}

Usage in a Component

components/InvoiceForm.tsx
// components/InvoiceForm.tsx
"use client";

import { useMagic } from "@/hooks/use-magic";

export function InvoiceForm() {
  const { execute, loading, error, requestId } = useMagic<
    { customer_id: string; invoice_data: object },
    { sync_id: string; status: string }
  >("InvoiceSync");

  async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
    e.preventDefault();
    const formData = new FormData(e.currentTarget);

    await execute({
      customer_id: formData.get("customerId") as string,
      invoice_data: { amount: Number(formData.get("amount")), currency: "usd" },
    });
  }

  return (
    <form onSubmit={handleSubmit}>
      <input name="customerId" placeholder="Customer ID" required />
      <input name="amount" type="number" placeholder="Amount (cents)" required />
      <button type="submit" disabled={loading}>
        {loading ? "Syncing..." : "Sync Invoice"}
      </button>
      {error && <p className="error">{error.message}</p>}
      {requestId && <p className="meta">Request: {requestId}</p>}
    </form>
  );
}

Error Handling

Magic Runtime uses structured E-codes to categorize errors. Map these to appropriate frontend behavior.

Code Category Meaning Frontend Action
E1xxx CONTRACT Input/output schema violation Show validation errors to user
E2xxx CAPABILITY Controller lacks permission Internal error — do not expose
E3xxx EXECUTION Controller runtime error Show generic error + request_id
E4xxx DEPLOYMENT Controller not found Check controller name spelling
E5xxx AUTH Authentication failure Redirect to login

Error Classification Helper

lib/magic-errors.ts
// lib/magic-errors.ts

export function classifyError(code: string): "validation" | "auth" | "internal" {
  if (code.startsWith("E1")) return "validation";
  if (code.startsWith("E5")) return "auth";
  return "internal";
}

export function userMessage(code: string, detail?: string): string {
  switch (classifyError(code)) {
    case "validation":
      return detail || "Please check your input and try again.";
    case "auth":
      return "Your session has expired. Please log in again.";
    case "internal":
      return "Something went wrong. Please try again or contact support.";
  }
}

Debugging

Always log the request_id — it maps directly to Magic Runtime traces for debugging.

Authentication

Configure your environment with the Magic Runtime URL and API token.

.env.local (Next.js)
# .env.local (Next.js)
NEXT_PUBLIC_MAGIC_URL=https://magic.your-company.com

# Mode A: API Key (service-to-service, Server Actions, SSR)
# This is the ADMIN_API_KEY value from your Magic Runtime .env file
MAGIC_API_KEY=your-admin-api-key-from-magic-runtime

# Mode B: Bearer / JWT for controller routes is planned but NOT yet supported.
# For browser-side LLM access, use the LLM Gateway subject-session flow.
# See: /llm-gateway.html

Warning

MAGIC_API_KEY is a server-side secret. Use the NEXT_PUBLIC_ prefix only for the base URL. The key must never reach the browser.

Pattern When to Use
X-API-Key (API Key) Server Actions, API Routes, SSR, scripts — use MAGIC_API_KEY env var
Authorization: Bearer (JWT) Planned. Bearer/JWT auth for controller routes is not yet available. For browser-side LLM access, use LLM Gateway subject sessions.
Session Proxy Client components calling through your API route (API key stays server-side)

Real-Time Updates

For real-time data, use polling or server-sent events until WebSocket support arrives in a future release.

Polling Hook

hooks/use-magic-poll.ts
// hooks/use-magic-poll.ts
import { useEffect } from "react";
import { useMagic } from "./use-magic";

export function useMagicPoll<T>(
  controller: string,
  input: T,
  intervalMs: number = 5000
) {
  const { execute, data, loading } = useMagic(controller);

  useEffect(() => {
    execute(input);
    const id = setInterval(() => execute(input), intervalMs);
    return () => clearInterval(id);
  }, [controller, intervalMs]);

  return { data, loading };
}

Roadmap

WebSocket support is planned for v2.3 (Admin Console). Until then, use polling or server-sent events through your own API layer.

Testing

Test your Magic Runtime integration with mocked fetch calls using Vitest or Jest.

__tests__/magic-client.test.ts
// __tests__/magic-client.test.ts
import { describe, it, expect, vi } from "vitest";
import { magic } from "@/lib/magic-client";

// Mock fetch for tests
global.fetch = vi.fn();

describe("MagicClient", () => {
  it("executes a controller", async () => {
    (fetch as any).mockResolvedValueOnce({
      ok: true,
      json: async () => ({
        result: { sync_id: "inv_1", status: "completed" },
        request_id: "req_test",
        duration_ms: 50,
        controller: "InvoiceSync",
        version: "1.0.0",
      }),
    });

    const res = await magic.execute("InvoiceSync", {
      customer_id: "cust_1",
      invoice_data: { amount: 100 },
    });

    expect(res.result.status).toBe("completed");
    expect(fetch).toHaveBeenCalledWith(
      expect.stringContaining("/api/v1/controllers/execute"),
      expect.objectContaining({ method: "POST" })
    );
  });

  it("throws MagicExecutionError on failure", async () => {
    (fetch as any).mockResolvedValueOnce({
      ok: false,
      json: async () => ({
        error: "Schema validation failed",
        code: "E1001",
        request_id: "req_err",
      }),
    });

    await expect(
      magic.execute("InvoiceSync", { bad: "input" })
    ).rejects.toThrow("Schema validation failed");
  });
});

When to Use Controllers vs. LLM Gateway

Use Magic Controllers

Choose controllers when you need sandboxed execution, typed contracts, capability controls, and governed runtime behavior.

Use LLM Gateway

Choose the gateway when you need governed access to frontier models with provider routing, policy controls, browser-safe sessions, and cost visibility.

Use Both Together

Let your application talk to Magic Runtime while Magic uses LLM Gateway as a black box for multi-provider AI access.

Browser access: Keep raw API keys on the server. For browser-side LLM access, use the subject-session flow with PKCE and signed requests instead of exposing API keys.

Next Steps