# Custom API routes

By default, Mastra automatically exposes registered agents and workflows via its server. For additional behavior you can define your own HTTP routes.

Routes are provided with a helper `registerApiRoute()` from `@mastra/core/server`. Routes can live in the same file as the `Mastra` instance but separating them helps keep configuration concise.

```typescript
import { Mastra } from '@mastra/core'
import { registerApiRoute } from '@mastra/core/server'

export const mastra = new Mastra({
  server: {
    apiRoutes: [
      registerApiRoute('/my-custom-route', {
        method: 'GET',
        handler: async c => {
          const mastra = c.get('mastra')
          const agent = await mastra.getAgent('my-agent')

          return c.json({ message: 'Custom route' })
        },
      }),
    ],
  },
})
```

Once registered, a custom route will be accessible from the root of the server. For example:

```bash
curl http://localhost:4111/my-custom-route
```

Each route's handler receives the Hono `Context`. Within the handler you can access the `Mastra` instance to fetch or call agents and workflows.

## Middleware

To add route-specific middleware pass a `middleware` array when calling `registerApiRoute()`.

```typescript
import { Mastra } from '@mastra/core'
import { registerApiRoute } from '@mastra/core/server'

export const mastra = new Mastra({
  server: {
    apiRoutes: [
      registerApiRoute('/my-custom-route', {
        method: 'GET',
        middleware: [
          async (c, next) => {
            console.log(`${c.req.method} ${c.req.url}`)
            await next()
          },
        ],
        handler: async c => {
          return c.json({ message: 'Custom route with middleware' })
        },
      }),
    ],
  },
})
```

## OpenAPI documentation

Custom routes can include OpenAPI metadata to appear in the Swagger UI alongside Mastra server routes. You can access the OpenAPI spec at `/api/openapi.json`, where both custom routes and built-in routes are listed. Pass an `openapi` option with standard OpenAPI operation fields.

```typescript
import { Mastra } from '@mastra/core'
import { registerApiRoute } from '@mastra/core/server'
import { z } from 'zod'

export const mastra = new Mastra({
  server: {
    apiRoutes: [
      registerApiRoute('/items/:itemId', {
        method: 'GET',
        openapi: {
          summary: 'Get item by ID',
          description: 'Retrieves a single item by its unique identifier',
          tags: ['Items'],
          parameters: [
            {
              name: 'itemId',
              in: 'path',
              required: true,
              description: 'The item ID',
              schema: { type: 'string' },
            },
          ],
          responses: {
            200: {
              description: 'Item found',
              content: {
                'application/json': {
                  schema: {
                    type: 'object',
                    properties: {
                      id: { type: 'string' },
                      name: { type: 'string' },
                    },
                  },
                },
              },
            },
            404: {
              description: 'Item not found',
            },
          },
        },
        handler: async c => {
          const itemId = c.req.param('itemId')
          return c.json({ id: itemId, name: 'Example Item' })
        },
      }),
    ],
  },
})
```

### Using Zod Schemas

Zod schemas in the `openapi` configuration are converted to JSON Schema when the OpenAPI document is generated:

```typescript
import { Mastra } from '@mastra/core'
import { registerApiRoute } from '@mastra/core/server'
import { z } from 'zod'

const ItemSchema = z.object({
  id: z.string(),
  name: z.string(),
  price: z.number(),
})

const CreateItemSchema = z.object({
  name: z.string().min(1),
  price: z.number().positive(),
})

export const mastra = new Mastra({
  server: {
    apiRoutes: [
      registerApiRoute('/items', {
        method: 'POST',
        openapi: {
          summary: 'Create a new item',
          tags: ['Items'],
          requestBody: {
            required: true,
            content: {
              'application/json': {
                schema: CreateItemSchema,
              },
            },
          },
          responses: {
            201: {
              description: 'Item created',
              content: {
                'application/json': {
                  schema: ItemSchema,
                },
              },
            },
          },
        },
        handler: async c => {
          const body = await c.req.json()
          return c.json({ id: 'new-id', ...body }, 201)
        },
      }),
    ],
  },
})
```

### Viewing in Swagger UI

When running in development mode (`mastra dev`) or with `swaggerUI: true` in build options, your custom routes appear in the Swagger UI at `/swagger-ui`.

```typescript
export const mastra = new Mastra({
  server: {
    build: {
      swaggerUI: true, // Enable in production builds
    },
    apiRoutes: [
      // Your routes...
    ],
  },
})
```

## Authentication

When authentication is configured on your Mastra server, custom API routes require authentication by default. To make a route publicly accessible, set `requiresAuth: false`:

```typescript
import { Mastra } from '@mastra/core'
import { registerApiRoute } from '@mastra/core/server'
import { MastraJwtAuth } from '@mastra/auth'

export const mastra = new Mastra({
  server: {
    auth: new MastraJwtAuth({
      secret: process.env.MASTRA_JWT_SECRET,
    }),
    apiRoutes: [
      // Protected route (default behavior)
      registerApiRoute('/protected-data', {
        method: 'GET',
        handler: async c => {
          // Access authenticated user from request context
          const user = c.get('requestContext').get('user')
          return c.json({ message: 'Authenticated user', user })
        },
      }),

      // Public route (no authentication required)
      registerApiRoute('/webhooks/github', {
        method: 'POST',
        requiresAuth: false, // Explicitly opt out of authentication
        handler: async c => {
          const payload = await c.req.json()
          // Process webhook without authentication
          return c.json({ received: true })
        },
      }),
    ],
  },
})
```

### Authentication behavior

- **No auth configured**: All routes (built-in and custom) are public

- **Auth configured**:

  - Mastra-provided routes (`/api/agents/*`, `/api/workflows/*`, etc.) require authentication
  - Custom routes require authentication by default
  - Custom routes can opt out with `requiresAuth: false`

### Accessing user information

When a request is authenticated, the user object is available in the request context:

```typescript
registerApiRoute('/user-profile', {
  method: 'GET',
  handler: async c => {
    const requestContext = c.get('requestContext')
    const user = requestContext.get('user')

    return c.json({ user })
  },
})
```

For more information about authentication providers, see the [Auth documentation](https://mastra.ai/docs/server/auth).

## Continue generation after client disconnect

Built-in streaming helpers such as [`chatRoute()`](https://mastra.ai/reference/ai-sdk/chat-route) forward the incoming request's `AbortSignal` to `agent.stream()`. That's the right default when a browser disconnect should cancel the model call.

If you want the server to keep generating and persist the final response even after the client disconnects, build a custom route around the underlying `MastraModelOutput`. Start the agent stream without forwarding `c.req.raw.signal`, then call `consumeStream()` in the background so generation continues server-side.

```typescript
import {
  createUIMessageStream,
  createUIMessageStreamResponse,
  InferUIMessageChunk,
  UIMessage,
} from 'ai'
import { toAISdkStream } from '@mastra/ai-sdk'
import { Mastra } from '@mastra/core'
import { registerApiRoute } from '@mastra/core/server'

export const mastra = new Mastra({
  server: {
    apiRoutes: [
      registerApiRoute('/chat/persist/:agentId', {
        method: 'POST',
        handler: async c => {
          const { messages, memory } = await c.req.json()
          const mastra = c.get('mastra')
          const agent = mastra.getAgent(c.req.param('agentId'))

          const stream = await agent.stream(messages, {
            memory,
            // Do not pass c.req.raw.signal if this route should keep running
            // after the client disconnects.
          })

          void stream.consumeStream().catch(error => {
            mastra.getLogger()?.error('Background stream consumption failed', { error })
          })

          const uiStream = createUIMessageStream({
            originalMessages: messages,
            execute: async ({ writer }) => {
              for await (const part of toAISdkStream(stream, { from: 'agent' })) {
                writer.write(part as InferUIMessageChunk<UIMessage>)
              }
            },
          })

          return createUIMessageStreamResponse({ stream: uiStream })
        },
      }),
    ],
  },
})
```

> **Note:** Use this pattern only when you intentionally want work to continue after the HTTP client is gone. If you want disconnects to cancel generation, keep using `chatRoute()` or forward the request `AbortSignal` yourself.

## Related

- [registerApiRoute() Reference](https://mastra.ai/reference/server/register-api-route) - Full API reference
- [Server Middleware](https://mastra.ai/docs/server/middleware) - Global middleware configuration
- [Mastra Server](https://mastra.ai/docs/server/mastra-server) - Server configuration options