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
# 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
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
"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
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
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
"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
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)
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
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
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.