Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
82 changes: 81 additions & 1 deletion src/agents/agents.spec.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,13 @@
import * as jose from 'jose';
import fetch from 'jest-fetch-mock';
import { fetchBody, fetchOnce, fetchURL } from '../common/utils/test-utils';
import {
fetchBody,
fetchMethod,
fetchOnce,
fetchURL,
} from '../common/utils/test-utils';
import { WorkOS } from '../workos';
import createClaimAttemptFixture from './fixtures/create-claim-attempt.json';
import getAgentRegistrationFixture from './fixtures/get-agent-registration.json';
import validateAgentCredentialFixture from './fixtures/validate-agent-credential.json';

Expand Down Expand Up @@ -48,6 +54,80 @@ describe('Agents', () => {
jest.mocked(jose.jwtVerify).mockReset();
});

describe('linkClaimAttemptToExternalUser', () => {
it('sends the request and deserializes the response', async () => {
fetchOnce(createClaimAttemptFixture);

const result = await workos.agents.linkClaimAttemptToExternalUser({
claimAttemptToken: 'cla_tkn_01EHWNCE74X7JSDV0X3SZ3KJNY',
user: {
email: 'alice@example.com',
externalId: 'user_abc123',
},
});

expect(fetchURL()).toContain('/agents/claims/attempts');
expect(fetchMethod()).toBe('PATCH');
expect(fetchBody()).toEqual({
type: 'link_external_user',
claim_attempt_token: 'cla_tkn_01EHWNCE74X7JSDV0X3SZ3KJNY',
user: {
email: 'alice@example.com',
external_id: 'user_abc123',
},
});
expect(result).toEqual({
id: 'agent_reg_01EHZNVPK3SFK441A1RGBFSHRT',
status: 'unverified',
userCode: 'BCDF-GHJK',
organizations: [
{
id: 'org_01EHZNVPK3SFK441A1RGBFSHRT',
name: 'Acme Corp',
},
],
});
});

it('includes organizationId when provided', async () => {
fetchOnce(createClaimAttemptFixture);

await workos.agents.linkClaimAttemptToExternalUser({
claimAttemptToken: 'cla_tkn_01EHWNCE74X7JSDV0X3SZ3KJNY',
user: {
email: 'alice@example.com',
externalId: 'user_abc123',
},
organizationId: 'org_01EHZNVPK3SFK441A1RGBFSHRT',
});

expect(fetchBody()).toEqual({
type: 'link_external_user',
claim_attempt_token: 'cla_tkn_01EHWNCE74X7JSDV0X3SZ3KJNY',
user: {
email: 'alice@example.com',
external_id: 'user_abc123',
},
organization_id: 'org_01EHZNVPK3SFK441A1RGBFSHRT',
});
});

it('omits organizationId from the payload when not provided', async () => {
fetchOnce(createClaimAttemptFixture);

await workos.agents.linkClaimAttemptToExternalUser({
claimAttemptToken: 'cla_tkn_01EHWNCE74X7JSDV0X3SZ3KJNY',
user: {
email: 'alice@example.com',
externalId: 'user_abc123',
},
});

const body = fetchBody();
expect(body).not.toHaveProperty('organization_id');
});
});

describe('getRegistration', () => {
it('retrieves an agent registration', async () => {
fetchOnce(getAgentRegistrationFixture);
Expand Down
31 changes: 31 additions & 0 deletions src/agents/agents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,10 @@ import { WorkOS } from '../workos';
import {
AgentCredentialValidation,
AgentRegistration,
ClaimAttemptResponse,
LinkClaimAttemptToExternalUserOptions,
SerializedAgentAccessTokenClaims,
SerializedClaimAttemptResponse,
SerializedAgentCredentialValidation,
SerializedAgentRegistration,
ValidateAgentAccessTokenOptions,
Expand All @@ -13,6 +16,8 @@ import {
deserializeAgentAccessTokenClaims,
deserializeAgentCredentialValidation,
deserializeAgentRegistration,
deserializeClaimAttemptResponse,
serializeLinkClaimAttemptToExternalUserOptions,
serializeValidateAgentCredentialOptions,
} from './serializers';

Expand Down Expand Up @@ -41,6 +46,32 @@ export class Agents {

constructor(private readonly workos: WorkOS) {}

/**
* Link a claim attempt to an external user
*
* Link an external user to a claim attempt and retrieve the code needed
* for the agent to complete the claim. The user is looked up by external
* ID; if no user exists, one is created. When the user belongs to multiple
* organizations, an explicit organization must be provided.
*
* @param options - Object containing the claim attempt token, user details, and optional organization ID.
* @returns {Promise<ClaimAttemptResponse>}
* @throws {BadRequestException} 400 - Invalid request, email mismatch, or wrong account.
* @throws {ForbiddenException} 403 - Claim denied or auth method disabled.
* @throws {ConflictException} 409 - Organization selection required, external ID conflict, or already claimed.
* @throws {GoneException} 410 - Claim or user code expired.
*/
async linkClaimAttemptToExternalUser(

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't have a strong opinion here on the name, but just flagging that this is different from the other SDKs' generated method (createAttempt) so I'm wondering if we should rename this to something more similar or the same.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This name was a deliberate choice by @m0tzy earlier in the design of this endpoint. The autogenerated SDKs surface a generic createAttempt(type=...) because they're driven directly off the OpenAPI operation, but for the hand-written Node method Madison wanted the name to describe what the operation actually does — link an external user to a claim attempt — rather than expose the lower-level type discriminator. The corresponding docs/API summary was also updated to "Link a claim attempt to an external user" to match.

Happy to rename to align with the generated SDKs (createAttempt) if the team would rather keep them consistent — deferring to @m0tzy on the final call since it was a product decision.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update on this: we resolved the divergence by switching the endpoint from POST to PATCH /agents/claims/attempts (it modifies an existing claim attempt rather than creating one — the agent already created it). The generated SDKs now surface a generic updateAttempts({ type: 'link_external_user', ... }), where the type arg disambiguates the operation. This hand-written Node SDK keeps the ergonomic linkClaimAttemptToExternalUser(...) wrapper over the same PATCH call (it hardcodes type internally). So the remaining name difference is intentional — hand-written SDKs are allowed to be friendlier than the generated ones. Server + docs are updated in workos/workos#64535.

options: LinkClaimAttemptToExternalUserOptions,
): Promise<ClaimAttemptResponse> {
Comment thread
m0tzy marked this conversation as resolved.
const { data } = await this.workos.patch<SerializedClaimAttemptResponse>(
'/agents/claims/attempts',
serializeLinkClaimAttemptToExternalUserOptions(options),
);
Comment on lines +67 to +70

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 HTTP method contradicts the PR description

The PR description explicitly states this wraps POST /agents/claims/attempts, but the implementation calls this.workos.patch(...). If the WorkOS API endpoint only accepts POST, every call to linkClaimAttemptToExternalUser will fail with a 405 Method Not Allowed response. The tests assert 'PATCH' which keeps them consistent with the implementation, but they won't catch this kind of server-side rejection. Please confirm the intended HTTP verb against the API spec and align the implementation, tests, and PR description accordingly.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/agents.ts
Line: 67-70

Comment:
**HTTP method contradicts the PR description**

The PR description explicitly states this wraps `POST /agents/claims/attempts`, but the implementation calls `this.workos.patch(...)`. If the WorkOS API endpoint only accepts `POST`, every call to `linkClaimAttemptToExternalUser` will fail with a `405 Method Not Allowed` response. The tests assert `'PATCH'` which keeps them consistent with the implementation, but they won't catch this kind of server-side rejection. Please confirm the intended HTTP verb against the API spec and align the implementation, tests, and PR description accordingly.

How can I resolve this? If you propose a fix, please make it concise.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PATCH is the intended verb — the endpoint was switched from POST to PATCH in the companion API PR (workos/workos#64535) since it modifies an already-existing claim attempt. I verified the server accepts PATCH via the API e2e suite (agent-admin.controller.e2e-spec.ts, all cases green). The stale "POST" in the description was the only mismatch — I've updated the PR description to say PATCH. No 405 risk.


return deserializeClaimAttemptResponse(data);
}

/**
* Get an agent registration
*
Expand Down
11 changes: 11 additions & 0 deletions src/agents/fixtures/create-claim-attempt.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"id": "agent_reg_01EHZNVPK3SFK441A1RGBFSHRT",
"status": "unverified",
"user_code": "BCDF-GHJK",
"organizations": [
{
"id": "org_01EHZNVPK3SFK441A1RGBFSHRT",
"name": "Acme Corp"
}
]
}
53 changes: 53 additions & 0 deletions src/agents/interfaces/claim-attempt.interface.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import { AgentRegistrationStatus } from './agent-registration.interface';

/** Options for linking an external user to a claim attempt via the admin API. */
export interface LinkClaimAttemptToExternalUserOptions {
/** The claim attempt token identifying the pending claim. */
claimAttemptToken: string;
/** The user to attach to the claim attempt. */
user: {
/** The email address of the user. */
email: string;
/** The external ID of the user. */
externalId: string;
};
/** The organization to place the agent in. Required when the user belongs to multiple organizations. */
organizationId?: string;
}

export interface SerializedLinkClaimAttemptToExternalUserOptions {
type: 'link_external_user';
claim_attempt_token: string;
user: {
email: string;
external_id: string;
};
organization_id?: string;
}

/** An organization the confirming user belongs to, offered as a placement choice. */
export interface ClaimAttemptOrganization {
/** The organization ID. */
id: string;
/** The organization name. */
name: string;
}

/** The result of linking an external user to a claim attempt. */
export interface ClaimAttemptResponse {
/** The agent registration ID. */
id: string;
/** Current status of the agent registration. */
status: AgentRegistrationStatus;
/** The user code the agent needs to complete the claim. */
userCode: string;
/** Organizations the user belongs to, offered as placement choices. */
organizations: ClaimAttemptOrganization[];
}

export interface SerializedClaimAttemptResponse {
id: string;
status: AgentRegistrationStatus;
user_code: string;
organizations: ClaimAttemptOrganization[];
}
1 change: 1 addition & 0 deletions src/agents/interfaces/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './agent-registration.interface';
export * from './claim-attempt.interface';
export * from './validate-agent-credential.interface';
33 changes: 33 additions & 0 deletions src/agents/serializers/claim-attempt.serializer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import {
ClaimAttemptResponse,
LinkClaimAttemptToExternalUserOptions,
SerializedClaimAttemptResponse,
SerializedLinkClaimAttemptToExternalUserOptions,
} from '../interfaces/claim-attempt.interface';

export function serializeLinkClaimAttemptToExternalUserOptions(
options: LinkClaimAttemptToExternalUserOptions,
): SerializedLinkClaimAttemptToExternalUserOptions {
return {
type: 'link_external_user',
claim_attempt_token: options.claimAttemptToken,
user: {
email: options.user.email,
external_id: options.user.externalId,
},
...(options.organizationId !== undefined && {
organization_id: options.organizationId,
}),
};
}

export function deserializeClaimAttemptResponse(
response: SerializedClaimAttemptResponse,
): ClaimAttemptResponse {
return {
id: response.id,
status: response.status,
userCode: response.user_code,
organizations: response.organizations,
};
}
1 change: 1 addition & 0 deletions src/agents/serializers/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './agent-registration.serializer';
export * from './claim-attempt.serializer';
export * from './validate-agent-credential.serializer';