Skip to main content

Custom channel auth

Verify Clerk callers at your eve agent's channel boundary — accept web sessions, API keys, and machine tokens, and map each to an eve principal your tools and instructions can read.

Tip

Skip the manual setup — install the configurable clerkAuth() helper via shadcn.

terminal
bunx --bun shadcn@latest add clerk/eve-agents/auth

Everything below builds the channel AuthFn from scratch: session verification first, then multi-token support and attribute mapping.

An eve channel authorizes inbound requests through a list of AuthFn callbacks. Each entry receives the request, returns an eve principal to accept, or returns null to skip to the next entry.

import type { AuthFn } from 'eve/channels/auth'

const myAuth: AuthFn<Request> = async (request) => {
  // Return a SessionAuthContext to accept, or `null` to skip.
}

Verify a Clerk session

Hand the Request to Clerk's authenticateRequest() and map the verified state to an eve principal.

agent/channels/eve.ts
import { createClerkClient } from '@clerk/backend'
import type { AuthFn } from 'eve/channels/auth'
import { eveChannel } from 'eve/channels/eve'

const clerk = createClerkClient({
  secretKey: process.env.CLERK_SECRET_KEY,
  publishableKey: process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY,
})

const clerkAuth: AuthFn<Request> = async (request) => {
  const state = await clerk
    .authenticateRequest(request, { acceptsToken: 'session_token' })
    .catch(() => null)

  if (!state?.isAuthenticated) return null

  const auth = state.toAuth()

  return {
    authenticator: 'clerk',
    principalType: 'user',
    principalId: auth.userId,
    subject: auth.userId,
    attributes: { tokenType: auth.tokenType },
  }
}

export default eveChannel({
  auth: [clerkAuth],
})

The returned object is what every downstream piece (instructions, tools, subagents) reads as ctx.session.auth.current.

Reject vs. fall through

Returning null skips this authenticator and passes the request to the next entry in the channel's auth chain. If no entry accepts, the request is rejected. Throw UnauthenticatedError instead to short-circuit the chain with a 401 — useful when Clerk is a required authentication strategy.

import { UnauthenticatedError } from 'eve/channels/auth'

if (!state?.isAuthenticated) {
  throw new UnauthenticatedError({
    code: 'authentication_required',
    message: `Clerk auth failed (${state?.reason ?? 'unknown'})`,
  })
}

Accept multiple token types

Pass an array to acceptsToken to verify more than one Clerk token type in the same call. Switch on auth.tokenType to branch on what authenticated.

agent/channels/eve.ts
const clerkAuth: AuthFn<Request> = async (request) => {
  const state = await clerk
    .authenticateRequest(request, {
      acceptsToken: ['session_token', 'api_key', 'm2m_token', 'oauth_token'],
      machineSecretKey: process.env.CLERK_MACHINE_SECRET_KEY,
    })
    .catch(() => null)

  if (!state?.isAuthenticated) return null

  const auth = state.toAuth()

  if (auth.tokenType === 'session_token') {
    return {
      authenticator: 'clerk',
      principalType: 'user',
      principalId: auth.userId,
      subject: auth.userId,
      attributes: { tokenType: auth.tokenType },
    }
  }

  // API key, M2M, OAuth — Clerk groups these as machine principals.
  return {
    authenticator: 'clerk',
    principalType: 'machine',
    principalId: auth.subject,
    subject: auth.subject,
    attributes: { tokenType: auth.tokenType },
  }
}

machineSecretKey is required when accepting m2m_token. Clerk uses it to verify the inbound token. See API keys & M2M for setup.

Add attributes

Attributes is a free-form object exposed as shared auth context in instructions, tools, and subagents. Customize it by pulling fields off the Clerk auth object.

Session token:

const attributes: Record<string, string | readonly string[]> = {
  tokenType: auth.tokenType,
}

if (auth.orgId) attributes.orgId = auth.orgId
if (auth.orgRole) attributes.role = auth.orgRole
if (auth.orgPermissions?.length) attributes.permissions = auth.orgPermissions

You can pull anything off auth.sessionClaims to enrich attributes too — useful for custom claims you've added to the session token.

const name = auth.sessionClaims?.name
if (typeof name === 'string') attributes.name = name

Machine variants all share scopes, with additional fields specific to each token type:

const attributes: Record<string, string | readonly string[]> = {
  tokenType: auth.tokenType,
}
if (auth.scopes?.length) attributes.scopes = auth.scopes

if (auth.tokenType === 'api_key') {
  // API keys are scoped to either a user or an org, never both.
  if (auth.userId) attributes.userId = auth.userId
  if (auth.orgId) attributes.orgId = auth.orgId
}

if (auth.tokenType === 'oauth_token') {
  attributes.userId = auth.userId
  attributes.clientId = auth.clientId
}

Summary by token type:

Token typeprincipalTypeTypical attributes
session_tokenuserorgId, role, permissions, name
api_keymachinescopes, userId or orgId
m2m_tokenmachinescopes
oauth_tokenmachinescopes, userId, clientId

See Enrich instructions and Authorize tool calls for how to read these downstream.

Compose with other authenticators

Add other entries to the auth chain. Each runs in order; the first non-null wins.

import { vercelOidc } from 'eve/channels/auth'

export default eveChannel({
  auth: [clerkAuth, vercelOidc()],
})

Caution

Omitting localDev() means unauthenticated requests get a real 401 on localhost. Useful for testing real auth flows. Remember the agent won't be reachable from your browser without signing in.

Feedback

What did you think of this content?

Last updated on