Skip to content
5 changes: 5 additions & 0 deletions .changeset/eighty-bobcats-unite.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/backend': minor
---

Add support for Agent Tasks API endpoint which allows developers to create agent tasks that can be used to act on behalf of users through automated flows.
5 changes: 5 additions & 0 deletions .changeset/gentle-falcons-arrive.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@clerk/testing': minor
---

Export `createAgentTestingTask` helper for creating agent tasks via the Clerk Backend API from both `@clerk/testing/playwright` and `@clerk/testing/cypress` subpaths.
96 changes: 96 additions & 0 deletions packages/backend/src/api/__tests__/AgentTaskApi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { http, HttpResponse } from 'msw';
import { describe, expect, it } from 'vitest';

import { server, validateHeaders } from '../../mock-server';
import { createBackendApiClient } from '../factory';

describe('AgentTaskAPI', () => {
const apiClient = createBackendApiClient({
apiUrl: 'https://api.clerk.test',
secretKey: 'deadbeef',
});

const mockAgentTaskResponse = {
object: 'agent_task',
agent_id: 'agent_123',
task_id: 'task_456',
url: 'https://example.com/agent-task',
};

describe('create', () => {
it('converts nested onBehalfOf.userId to snake_case', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/agents/tasks',
validateHeaders(async ({ request }) => {
const body = await request.json();

expect(body).toEqual({
on_behalf_of: {
user_id: 'user_123',
},
permissions: 'read,write',
agent_name: 'test-agent',
task_description: 'Test task',
redirect_url: 'https://example.com/callback',
session_max_duration_in_seconds: 1800,
});

return HttpResponse.json(mockAgentTaskResponse);
}),
),
);

const response = await apiClient.agentTasks.create({
onBehalfOf: {
userId: 'user_123',
},
permissions: 'read,write',
agentName: 'test-agent',
taskDescription: 'Test task',
redirectUrl: 'https://example.com/callback',
sessionMaxDurationInSeconds: 1800,
});

expect(response.agentId).toBe('agent_123');
expect(response.taskId).toBe('task_456');
expect(response.url).toBe('https://example.com/agent-task');
});

it('converts nested onBehalfOf.identifier to snake_case', async () => {
server.use(
http.post(
'https://api.clerk.test/v1/agents/tasks',
validateHeaders(async ({ request }) => {
const body = await request.json();

expect(body).toEqual({
on_behalf_of: {
identifier: 'user@example.com',
},
permissions: 'read',
agent_name: 'test-agent',
task_description: 'Test task',
redirect_url: 'https://example.com/callback',
});

return HttpResponse.json(mockAgentTaskResponse);
}),
),
);

const response = await apiClient.agentTasks.create({
onBehalfOf: {
identifier: 'user@example.com',
},
permissions: 'read',
agentName: 'test-agent',
taskDescription: 'Test task',
redirectUrl: 'https://example.com/callback',
});

expect(response.agentId).toBe('agent_123');
expect(response.taskId).toBe('task_456');
});
});
});
72 changes: 72 additions & 0 deletions packages/backend/src/api/endpoints/AgentTaskApi.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { joinPaths } from '../../util/path';
import type { AgentTask } from '../resources/AgentTask';
import { AbstractAPI } from './AbstractApi';

type CreateAgentTaskParams = {
/**
* The user to create an agent task for.
*/
onBehalfOf:
| {
/**
* The identifier of the user to create an agent task for.
*/
identifier: string;
userId?: never;
}
| {
/**
* The ID of the user to create an agent task for.
*/
userId: string;
identifier?: never;
};
/**
* The permissions the agent task will have.
*/
permissions: string;
/**
* The name of the agent to create an agent task for.
*/
agentName: string;
/**
* The description of the agent task to create.
*/
taskDescription: string;
/**
* The URL to redirect to after the agent task is consumed.
*/
redirectUrl: string;

/**
* The maximum duration that the session which will be created by the generated agent task should last.
* By default, the duration is 30 minutes.
*/
sessionMaxDurationInSeconds?: number;
};

const basePath = '/agents/tasks';

export class AgentTaskAPI extends AbstractAPI {
/**
* @experimental This is an experimental API for the Agent Tokens feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes.
*/
public async create(params: CreateAgentTaskParams) {
return this.request<AgentTask>({
method: 'POST',
path: basePath,
bodyParams: params,
options: {
deepSnakecaseBodyParamKeys: true,
},
});
}

public async revoke(agentTaskId: string) {
this.requireId(agentTaskId);
return this.request<Omit<AgentTask, 'url'>>({
method: 'POST',
path: joinPaths(basePath, agentTaskId, 'revoke'),
});
}
}
1 change: 1 addition & 0 deletions packages/backend/src/api/endpoints/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './ActorTokenApi';
export * from './AgentTaskApi';
export * from './AccountlessApplicationsAPI';
export * from './AbstractApi';
export * from './AllowlistIdentifierApi';
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/api/factory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import {
AccountlessApplicationAPI,
ActorTokenAPI,
AgentTaskAPI,
AllowlistIdentifierAPI,
APIKeysAPI,
BetaFeaturesAPI,
Expand Down Expand Up @@ -44,6 +45,10 @@ export function createBackendApiClient(options: CreateBackendApiOptions) {
buildRequest({ ...options, requireSecretKey: false }),
),
actorTokens: new ActorTokenAPI(request),
/**
* @experimental This is an experimental API for the Agent Tasks feature that is available under a private beta, and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version and the clerk-js version to avoid breaking changes.
*/
agentTasks: new AgentTaskAPI(request),
allowlistIdentifiers: new AllowlistIdentifierAPI(request),
apiKeys: new APIKeysAPI(
buildRequest({
Expand Down
34 changes: 34 additions & 0 deletions packages/backend/src/api/resources/AgentTask.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { AgentTaskJSON } from './JSON';

/**
* Represents a agent token resource.
*
* Agent tokens are used for testing purposes and allow creating sessions
* for users without requiring full authentication flows.
*/
export class AgentTask {
constructor(
/**
* A stable identifier for the agent, unique per agent_name within an instance.
*/
readonly agentId: string,
/**
* A unique identifier for this agent task.
*/
readonly taskId: string,
/**
* The FAPI URL that, when visited, creates a session for the user.
*/
readonly url: string,
) {}

/**
* Creates a AgentTask instance from a JSON object.
*
* @param data - The JSON object containing agent task data
* @returns A new AgentTask instance
*/
static fromJSON(data: AgentTaskJSON): AgentTask {
return new AgentTask(data.agent_id, data.task_id, data.url);
}
}
3 changes: 3 additions & 0 deletions packages/backend/src/api/resources/Deserializer.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {
ActorToken,
AgentTask,
AllowlistIdentifier,
APIKey,
BlocklistIdentifier,
Expand Down Expand Up @@ -169,6 +170,8 @@ function jsonToObject(item: any): any {
return SamlConnection.fromJSON(item);
case ObjectType.SignInToken:
return SignInToken.fromJSON(item);
case ObjectType.AgentTask:
return AgentTask.fromJSON(item);
case ObjectType.SignUpAttempt:
return SignUpAttempt.fromJSON(item);
case ObjectType.Session:
Expand Down
8 changes: 8 additions & 0 deletions packages/backend/src/api/resources/JSON.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import type {
export const ObjectType = {
AccountlessApplication: 'accountless_application',
ActorToken: 'actor_token',
AgentTask: 'agent_task',
AllowlistIdentifier: 'allowlist_identifier',
ApiKey: 'api_key',
BlocklistIdentifier: 'blocklist_identifier',
Expand Down Expand Up @@ -512,6 +513,13 @@ export interface SignInTokenJSON extends ClerkResourceJSON {
updated_at: number;
}

export interface AgentTaskJSON extends ClerkResourceJSON {
object: typeof ObjectType.AgentTask;
agent_id: string;
task_id: string;
url: string;
}

export interface SignUpJSON extends ClerkResourceJSON {
object: typeof ObjectType.SignUpAttempt;
id: string;
Expand Down
1 change: 1 addition & 0 deletions packages/backend/src/api/resources/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
export * from './AccountlessApplication';
export * from './AgentTask';
export * from './ActorToken';
export * from './AllowlistIdentifier';
export * from './APIKey';
Expand Down
2 changes: 2 additions & 0 deletions packages/backend/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ export type { VerifyTokenOptions } from './tokens/verify';
*/
export type {
ActorTokenJSON,
AgentTaskJSON,
AccountlessApplicationJSON,
ClerkResourceJSON,
TokenJSON,
Expand Down Expand Up @@ -110,6 +111,7 @@ export type {
* Resources
*/
export type {
AgentTask,
APIKey,
ActorToken,
AccountlessApplication,
Expand Down
63 changes: 63 additions & 0 deletions packages/testing/src/common/agent-task.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import type { AgentTask, ClerkClient } from '@clerk/backend';
import { createClerkClient } from '@clerk/backend';

export type CreateAgentTaskParams = Parameters<ClerkClient['agentTasks']['create']>[0] &
(
| {
/**
* The API URL for your Clerk instance.
* If not provided, falls back to the `CLERK_API_URL` environment variable.
*/
apiUrl?: string;
/**
* The secret key for your Clerk instance.
* If not provided, falls back to the `CLERK_SECRET_KEY` environment variable.
*/
secretKey?: string;

clerkClient?: never;
}
| {
/**
* The Clerk client to use to create the agent task.
* If not provided, a new Clerk client will be created.
*/
clerkClient?: ClerkClient;
apiUrl?: string;
secretKey?: string;
}
);

export const ERROR_MISSING_SECRET_KEY =
'A secretKey is required to create agent tasks. ' +
'Pass it directly or set the CLERK_SECRET_KEY environment variable.';

export const ERROR_MISSING_API_URL =
'An apiUrl is required to create agent tasks. ' + 'Pass it directly or set the CLERK_API_URL environment variable.';

export const ERROR_AGENT_TASK_FAILED = 'Failed to create agent task: ';

/**
* Creates an agent task using the Clerk Backend API and returns its URL.
*
* @internal Framework-specific wrappers should call this after resolving the secret key.
*
* @experimental This is an experimental API for the Agent Tasks feature that is available under a private beta,
* and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version
* and the clerk-js version to avoid breaking changes.
*/
export async function createAgentTestingTask(params: CreateAgentTaskParams): Promise<AgentTask> {
const { apiUrl, secretKey, clerkClient, ...taskParams } = params;

if (!clerkClient && !secretKey) {
throw new Error(ERROR_MISSING_SECRET_KEY);
}

const client = clerkClient ?? createClerkClient({ apiUrl, secretKey });

try {
return await client.agentTasks.create(taskParams);
} catch (error) {
throw new Error(ERROR_AGENT_TASK_FAILED + (error instanceof Error ? error.message : String(error)));
}
}
1 change: 1 addition & 0 deletions packages/testing/src/common/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export * from './agent-task';
export * from './constants';
export * from './types';
export * from './setup';
Expand Down
19 changes: 19 additions & 0 deletions packages/testing/src/cypress/agent-task.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
/// <reference types="cypress" />
import { type CreateAgentTaskParams, createAgentTestingTask as _createAgentTestingTask } from '../common';

/**
* Creates an agent task using the Clerk Backend API and returns its URL.
*
* If `secretKey` is not provided, falls back to the `CLERK_SECRET_KEY` Cypress environment variable.
*
* @experimental This is an experimental API for the Agent Tasks feature that is available under a private beta,
* and the API is subject to change. It is advised to [pin](https://clerk.com/docs/pinning) the SDK version
* and the clerk-js version to avoid breaking changes.
*/
export function createAgentTestingTask(params: CreateAgentTaskParams) {
return _createAgentTestingTask({
...params,
apiUrl: params.apiUrl || Cypress.env('CLERK_API_URL') || process.env.CLERK_API_URL,
secretKey: params.secretKey || Cypress.env('CLERK_SECRET_KEY') || process.env.CLERK_SECRET_KEY,
});
}
1 change: 1 addition & 0 deletions packages/testing/src/cypress/index.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export { clerkSetup } from './setup';
export { createAgentTestingTask } from './agent-task';
export { setupClerkTestingToken } from './setupClerkTestingToken';
export { addClerkCommands } from './custom-commands';
Loading
Loading