runline 0.14.1 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,65 @@
1
+ import * as t from "typebox";
2
+ export const STRICT_OBJECT = { additionalProperties: false };
3
+ export const STRICT_UPDATE_OBJECT = {
4
+ additionalProperties: false,
5
+ minProperties: 2,
6
+ };
7
+ export function idSchema(description) {
8
+ return t.String({ minLength: 1, pattern: "\\S", description });
9
+ }
10
+ export function cursorSchema(description = "Next-page cursor") {
11
+ return t.String({ minLength: 1, maxLength: 512, description });
12
+ }
13
+ export function timestampSchema(description) {
14
+ return t.String({
15
+ format: "date-time",
16
+ pattern: "Z$",
17
+ description,
18
+ });
19
+ }
20
+ export function enumDescription(name, values) {
21
+ return `${name}: ${values.join(" | ")}`;
22
+ }
23
+ export function enumSchema(name, values) {
24
+ return t.Union(values.map((value) => t.Literal(value)), { description: enumDescription(name, values) });
25
+ }
26
+ const SHIFT_API_URL = "https://cloud.shift-labs.ai";
27
+ export function baseUrl() {
28
+ return `${SHIFT_API_URL}/`;
29
+ }
30
+ export async function request(ctx, path, init = {}) {
31
+ const headers = new Headers(init.headers);
32
+ const apiKey = ctx.connection.config.apiKey;
33
+ if (typeof apiKey !== "string" || !apiKey) {
34
+ throw new Error("Shift Labs apiKey is required");
35
+ }
36
+ headers.set("authorization", `Bearer ${apiKey}`);
37
+ if (init.body && !headers.has("content-type")) {
38
+ headers.set("content-type", "application/json");
39
+ }
40
+ const response = await fetch(new URL(path, baseUrl()), {
41
+ ...init,
42
+ headers,
43
+ });
44
+ if (!response.ok) {
45
+ throw new Error(`Shift Labs API error ${response.status}: ${await response.text()}`);
46
+ }
47
+ if (response.status === 204)
48
+ return undefined;
49
+ return (await response.json());
50
+ }
51
+ export function pathSegment(value) {
52
+ return encodeURIComponent(value);
53
+ }
54
+ export function listParams(input) {
55
+ const params = new URLSearchParams();
56
+ for (const [key, value] of Object.entries((input ?? {}))) {
57
+ if (value !== undefined)
58
+ params.set(key, String(value));
59
+ }
60
+ return params;
61
+ }
62
+ export function withQuery(path, params) {
63
+ const query = params.toString();
64
+ return query ? `${path}?${query}` : path;
65
+ }
@@ -0,0 +1,45 @@
1
+ import * as t from "typebox";
2
+ import { CRM_ACCESS_ROLE, CRM_BASE, enumSchema, idSchema, listParams, pathSegment, request, STRICT_OBJECT, withQuery, } from "./shared.js";
3
+ export function registerAccessActions(rl) {
4
+ rl.registerAction("access.me", {
5
+ access: "read",
6
+ description: "Resolve the caller's CRM access role (user, admin, or none). CRM data routes require a user principal with an active grant.",
7
+ inputSchema: t.Object({}, STRICT_OBJECT),
8
+ async execute(_input, ctx) {
9
+ return request(ctx, `${CRM_BASE}/access/me`);
10
+ },
11
+ });
12
+ rl.registerAction("access.list", {
13
+ access: "read",
14
+ description: "List CRM access grants. Requires a CRM admin grant or admin:* permission.",
15
+ inputSchema: t.Object({ includeRevoked: t.Optional(t.Boolean()) }, STRICT_OBJECT),
16
+ async execute(input, ctx) {
17
+ const body = await request(ctx, withQuery(`${CRM_BASE}/access`, listParams(input)));
18
+ return body.grants;
19
+ },
20
+ });
21
+ rl.registerAction("access.grant", {
22
+ access: "write",
23
+ description: "Grant a Shift user CRM access. Requires a CRM admin grant or admin:* permission.",
24
+ inputSchema: t.Object({
25
+ userId: idSchema("Shift user ID to grant access to"),
26
+ role: t.Optional(enumSchema("CRM access role", CRM_ACCESS_ROLE)),
27
+ }, STRICT_OBJECT),
28
+ async execute(input, ctx) {
29
+ const body = await request(ctx, `${CRM_BASE}/access`, { method: "POST", body: JSON.stringify(input) });
30
+ return body.grant;
31
+ },
32
+ });
33
+ rl.registerAction("access.revoke", {
34
+ access: "write",
35
+ description: "Revoke a Shift user's CRM access grant. The grant is stamped revoked, not deleted.",
36
+ inputSchema: t.Object({ userId: idSchema("Shift user ID") }, STRICT_OBJECT),
37
+ async execute(input, ctx) {
38
+ const { userId } = input;
39
+ await request(ctx, `${CRM_BASE}/access/${pathSegment(userId)}`, {
40
+ method: "DELETE",
41
+ });
42
+ return { revoked: true };
43
+ },
44
+ });
45
+ }
@@ -0,0 +1,81 @@
1
+ import * as t from "typebox";
2
+ import { CRM_BASE, createRecordFields, idSchema, listParams, paginationFields, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, updateRecordFields, withQuery, } from "./shared.js";
3
+ const accountListFields = {
4
+ search: t.Optional(t.String({ minLength: 1, pattern: "\\S", description: "Name search" })),
5
+ accountType: t.Optional(t.String({ minLength: 1, description: "Account type filter" })),
6
+ lifecycleStage: t.Optional(t.String({ minLength: 1, description: "Lifecycle stage filter" })),
7
+ includeArchived: t.Optional(t.Boolean()),
8
+ ...paginationFields(),
9
+ };
10
+ function nullableString(maxLength, description) {
11
+ return t.Optional(t.Union([
12
+ t.String({ minLength: 1, maxLength, pattern: "\\S", description }),
13
+ t.Null(),
14
+ ]));
15
+ }
16
+ export function registerAccountActions(rl) {
17
+ rl.registerAction("account.list", {
18
+ access: "read",
19
+ description: "List the first page of CRM accounts.",
20
+ inputSchema: t.Object(accountListFields, STRICT_OBJECT),
21
+ async execute(input, ctx) {
22
+ const body = await request(ctx, withQuery(`${CRM_BASE}/accounts`, listParams(input)));
23
+ return body.accounts;
24
+ },
25
+ });
26
+ rl.registerAction("account.listPage", {
27
+ access: "read",
28
+ description: "List a cursor-paginated page of CRM accounts.",
29
+ inputSchema: t.Object(accountListFields, STRICT_OBJECT),
30
+ async execute(input, ctx) {
31
+ return request(ctx, withQuery(`${CRM_BASE}/accounts`, listParams(input)));
32
+ },
33
+ });
34
+ rl.registerAction("account.get", {
35
+ access: "read",
36
+ description: "Get a CRM account by record ID.",
37
+ inputSchema: t.Object({ id: idSchema("Account record ID") }, STRICT_OBJECT),
38
+ async execute(input, ctx) {
39
+ const { id } = input;
40
+ const body = await request(ctx, `${CRM_BASE}/accounts/${pathSegment(id)}`);
41
+ return body.account;
42
+ },
43
+ });
44
+ rl.registerAction("account.create", {
45
+ access: "write",
46
+ description: "Create a CRM account (organization, customer, partner, or competitor).",
47
+ inputSchema: t.Object({
48
+ name: t.String({
49
+ minLength: 1,
50
+ maxLength: 300,
51
+ pattern: "\\S",
52
+ description: "Account name",
53
+ }),
54
+ domain: t.Optional(t.String({ minLength: 1, maxLength: 300, pattern: "\\S" })),
55
+ accountType: t.Optional(t.String({ minLength: 1, maxLength: 100, pattern: "\\S" })),
56
+ lifecycleStage: t.Optional(t.String({ minLength: 1, maxLength: 100, pattern: "\\S" })),
57
+ ...createRecordFields(),
58
+ }, STRICT_OBJECT),
59
+ async execute(input, ctx) {
60
+ const body = await request(ctx, `${CRM_BASE}/accounts`, { method: "POST", body: JSON.stringify(input) });
61
+ return body.account;
62
+ },
63
+ });
64
+ rl.registerAction("account.update", {
65
+ access: "write",
66
+ description: "Update or archive a CRM account. Omitted fields are preserved; null clears a nullable field.",
67
+ inputSchema: t.Object({
68
+ id: idSchema("Account record ID"),
69
+ name: t.Optional(t.String({ minLength: 1, maxLength: 300, pattern: "\\S" })),
70
+ domain: nullableString(300, "Primary web domain"),
71
+ accountType: nullableString(100, "Account type"),
72
+ lifecycleStage: nullableString(100, "Lifecycle stage"),
73
+ ...updateRecordFields(),
74
+ }, STRICT_UPDATE_OBJECT),
75
+ async execute(input, ctx) {
76
+ const { id, ...patch } = input;
77
+ const body = await request(ctx, `${CRM_BASE}/accounts/${pathSegment(id)}`, { method: "PATCH", body: JSON.stringify(patch) });
78
+ return body.account;
79
+ },
80
+ });
81
+ }
@@ -0,0 +1,61 @@
1
+ import * as t from "typebox";
2
+ import { assertTimeOrder, CRM_ACTIVITY_DIRECTION, CRM_ACTIVITY_STATUS, CRM_BASE, createRecordFields, enumSchema, idSchema, listParams, paginationFields, request, STRICT_OBJECT, timestampSchema, withQuery, } from "./shared.js";
3
+ const activityListFields = {
4
+ linkedRecordId: t.Optional(idSchema("Filter by linked CRM record ID")),
5
+ status: t.Optional(enumSchema("Activity status", CRM_ACTIVITY_STATUS)),
6
+ ...paginationFields(),
7
+ };
8
+ export function registerActivityActions(rl) {
9
+ rl.registerAction("activity.list", {
10
+ access: "read",
11
+ description: "List the first page of CRM activities.",
12
+ inputSchema: t.Object(activityListFields, STRICT_OBJECT),
13
+ async execute(input, ctx) {
14
+ const body = await request(ctx, withQuery(`${CRM_BASE}/activities`, listParams(input)));
15
+ return body.activities;
16
+ },
17
+ });
18
+ rl.registerAction("activity.listPage", {
19
+ access: "read",
20
+ description: "List a cursor-paginated page of CRM activities.",
21
+ inputSchema: t.Object(activityListFields, STRICT_OBJECT),
22
+ async execute(input, ctx) {
23
+ return request(ctx, withQuery(`${CRM_BASE}/activities`, listParams(input)));
24
+ },
25
+ });
26
+ rl.registerAction("activity.log", {
27
+ access: "write",
28
+ description: "Log an append-only CRM activity (call, meeting, message, note). Must link to at least one CRM record via relationships.",
29
+ inputSchema: t.Object({
30
+ activityType: t.String({
31
+ minLength: 1,
32
+ maxLength: 100,
33
+ pattern: "\\S",
34
+ description: "Activity type, e.g. call, meeting, note",
35
+ }),
36
+ channel: t.Optional(t.String({ minLength: 1, maxLength: 100, pattern: "\\S" })),
37
+ direction: t.Optional(enumSchema("Activity direction", CRM_ACTIVITY_DIRECTION)),
38
+ status: t.Optional(enumSchema("Activity status", CRM_ACTIVITY_STATUS)),
39
+ subject: t.String({
40
+ minLength: 1,
41
+ maxLength: 500,
42
+ pattern: "\\S",
43
+ description: "Activity subject",
44
+ }),
45
+ body: t.Optional(t.String({ maxLength: 100_000 })),
46
+ startedAt: t.Optional(timestampSchema("ISO-8601 start timestamp")),
47
+ endedAt: t.Optional(timestampSchema("ISO-8601 end timestamp")),
48
+ externalKey: t.Optional(t.String({ minLength: 1, maxLength: 500, pattern: "\\S" })),
49
+ ...createRecordFields(),
50
+ }, STRICT_OBJECT),
51
+ async execute(input, ctx) {
52
+ const fields = input;
53
+ if (!fields.relationships || fields.relationships.length === 0) {
54
+ throw new Error("An activity must link to at least one CRM record via relationships");
55
+ }
56
+ assertTimeOrder(fields.startedAt, fields.endedAt, "Activity endedAt must not precede startedAt");
57
+ const body = await request(ctx, `${CRM_BASE}/activities`, { method: "POST", body: JSON.stringify(input) });
58
+ return body.activity;
59
+ },
60
+ });
61
+ }
@@ -0,0 +1,101 @@
1
+ import * as t from "typebox";
2
+ import { CRM_BASE, idSchema, pathSegment, recordTypeSchema, request, STRICT_OBJECT, } from "./shared.js";
3
+ export function registerImportActions(rl) {
4
+ rl.registerAction("import.create", {
5
+ access: "write",
6
+ description: "Create a CRM import run for a source system. Requires a CRM admin grant.",
7
+ inputSchema: t.Object({
8
+ sourceSystem: t.String({
9
+ minLength: 1,
10
+ maxLength: 100,
11
+ pattern: "\\S",
12
+ description: "Source system name, e.g. hubspot",
13
+ }),
14
+ }, STRICT_OBJECT),
15
+ async execute(input, ctx) {
16
+ const body = await request(ctx, `${CRM_BASE}/imports`, { method: "POST", body: JSON.stringify(input) });
17
+ return body.importRun;
18
+ },
19
+ });
20
+ rl.registerAction("import.list", {
21
+ access: "read",
22
+ description: "List CRM import runs. Requires a CRM admin grant.",
23
+ inputSchema: t.Object({}, STRICT_OBJECT),
24
+ async execute(_input, ctx) {
25
+ const body = await request(ctx, `${CRM_BASE}/imports`);
26
+ return body.importRuns;
27
+ },
28
+ });
29
+ rl.registerAction("import.get", {
30
+ access: "read",
31
+ description: "Get a CRM import run and its staged rows. Requires a CRM admin grant.",
32
+ inputSchema: t.Object({ id: idSchema("Import run ID") }, STRICT_OBJECT),
33
+ async execute(input, ctx) {
34
+ const { id } = input;
35
+ return request(ctx, `${CRM_BASE}/imports/${pathSegment(id)}`);
36
+ },
37
+ });
38
+ rl.registerAction("import.stageRows", {
39
+ access: "write",
40
+ description: "Stage immutable raw rows on an import run (1-500 per call). Each row carries its typed source identity and raw payload.",
41
+ inputSchema: t.Object({
42
+ importRunId: idSchema("Import run ID"),
43
+ rows: t.Array(t.Object({
44
+ sourceId: t.String({
45
+ minLength: 1,
46
+ maxLength: 500,
47
+ pattern: "\\S",
48
+ description: "Stable ID in the source system",
49
+ }),
50
+ recordType: recordTypeSchema(),
51
+ rawPayload: t.Unknown({
52
+ description: "Immutable raw source payload",
53
+ }),
54
+ }, { additionalProperties: false }), { minItems: 1, maxItems: 500 }),
55
+ }, STRICT_OBJECT),
56
+ async execute(input, ctx) {
57
+ const { importRunId, ...payload } = input;
58
+ const body = await request(ctx, `${CRM_BASE}/imports/${pathSegment(importRunId)}/rows`, { method: "POST", body: JSON.stringify(payload) });
59
+ return body.rows;
60
+ },
61
+ });
62
+ rl.registerAction("import.commitRow", {
63
+ access: "write",
64
+ description: "Commit one staged import row with its typed transformed payload. Record creation, row result, and change event commit atomically.",
65
+ inputSchema: t.Object({
66
+ importRunId: idSchema("Import run ID"),
67
+ rowId: idSchema("Import row ID"),
68
+ transformedPayload: t.Unknown({
69
+ description: "Typed create payload for the row's record type (matches the corresponding create action input)",
70
+ }),
71
+ }, STRICT_OBJECT),
72
+ async execute(input, ctx) {
73
+ const { importRunId, rowId, transformedPayload } = input;
74
+ const body = await request(ctx, `${CRM_BASE}/imports/${pathSegment(importRunId)}/rows/${pathSegment(rowId)}/commit`, { method: "POST", body: JSON.stringify({ transformedPayload }) });
75
+ return body.record;
76
+ },
77
+ });
78
+ rl.registerAction("import.skipRow", {
79
+ access: "write",
80
+ description: "Explicitly skip a staged import row without creating a record.",
81
+ inputSchema: t.Object({
82
+ importRunId: idSchema("Import run ID"),
83
+ rowId: idSchema("Import row ID"),
84
+ }, STRICT_OBJECT),
85
+ async execute(input, ctx) {
86
+ const { importRunId, rowId } = input;
87
+ const body = await request(ctx, `${CRM_BASE}/imports/${pathSegment(importRunId)}/rows/${pathSegment(rowId)}/skip`, { method: "POST" });
88
+ return body.row;
89
+ },
90
+ });
91
+ rl.registerAction("import.commit", {
92
+ access: "write",
93
+ description: "Commit an import run. Allowed only after every staged row is imported or skipped.",
94
+ inputSchema: t.Object({ id: idSchema("Import run ID") }, STRICT_OBJECT),
95
+ async execute(input, ctx) {
96
+ const { id } = input;
97
+ const body = await request(ctx, `${CRM_BASE}/imports/${pathSegment(id)}/commit`, { method: "POST" });
98
+ return body.importRun;
99
+ },
100
+ });
101
+ }
@@ -0,0 +1,28 @@
1
+ import * as t from "typebox";
2
+ import { registerAccessActions } from "./access.js";
3
+ import { registerAccountActions } from "./accounts.js";
4
+ import { registerActivityActions } from "./activities.js";
5
+ import { registerImportActions } from "./imports.js";
6
+ import { registerOpportunityActions, registerPipelineActions, } from "./opportunities.js";
7
+ import { registerPersonActions } from "./people.js";
8
+ import { registerRecordActions } from "./records.js";
9
+ import { registerTaskActions } from "./tasks.js";
10
+ export default function shiftCrm(rl) {
11
+ rl.setName("shiftCrm");
12
+ rl.setVersion("0.1.0");
13
+ rl.setConnectionSchema(t.Object({
14
+ apiKey: t.String({
15
+ description: "User-subject Shift CRM API key minted by an operator via `bun run crm:access key`. It authenticates as its bound user, whose active CRM access grant authorizes data access. Regular Shift cloud service API keys (SHIFT_LABS_API_KEY) cannot reach CRM data.",
16
+ env: "SHIFT_CRM_API_KEY",
17
+ }),
18
+ }));
19
+ registerAccessActions(rl);
20
+ registerAccountActions(rl);
21
+ registerPersonActions(rl);
22
+ registerPipelineActions(rl);
23
+ registerOpportunityActions(rl);
24
+ registerActivityActions(rl);
25
+ registerTaskActions(rl);
26
+ registerRecordActions(rl);
27
+ registerImportActions(rl);
28
+ }
@@ -0,0 +1,145 @@
1
+ import * as t from "typebox";
2
+ import { CRM_BASE, CRM_PIPELINE_OUTCOME, createRecordFields, enumSchema, idSchema, listParams, nullableTimestamp, paginationFields, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, updateRecordFields, withQuery, } from "./shared.js";
3
+ const opportunityListFields = {
4
+ accountId: t.Optional(idSchema("Filter by account record ID")),
5
+ pipelineId: t.Optional(idSchema("Filter by pipeline ID")),
6
+ stageId: t.Optional(idSchema("Filter by stage ID")),
7
+ includeArchived: t.Optional(t.Boolean()),
8
+ ...paginationFields(),
9
+ };
10
+ const probabilitySchema = t.Integer({
11
+ minimum: 0,
12
+ maximum: 100,
13
+ description: "Win probability percent",
14
+ });
15
+ export function registerPipelineActions(rl) {
16
+ rl.registerAction("pipeline.list", {
17
+ access: "read",
18
+ description: "List CRM opportunity pipelines with their ordered stages.",
19
+ inputSchema: t.Object({}, STRICT_OBJECT),
20
+ async execute(_input, ctx) {
21
+ const body = await request(ctx, `${CRM_BASE}/pipelines`);
22
+ return body.pipelines;
23
+ },
24
+ });
25
+ rl.registerAction("pipeline.get", {
26
+ access: "read",
27
+ description: "Get a CRM pipeline and its stages by ID.",
28
+ inputSchema: t.Object({ id: idSchema("Pipeline ID") }, STRICT_OBJECT),
29
+ async execute(input, ctx) {
30
+ const { id } = input;
31
+ const body = await request(ctx, `${CRM_BASE}/pipelines/${pathSegment(id)}`);
32
+ return body.pipeline;
33
+ },
34
+ });
35
+ rl.registerAction("pipeline.create", {
36
+ access: "write",
37
+ description: "Create a CRM opportunity pipeline with ordered stages. Requires a CRM admin grant.",
38
+ inputSchema: t.Object({
39
+ name: t.String({
40
+ minLength: 1,
41
+ maxLength: 200,
42
+ pattern: "\\S",
43
+ description: "Pipeline name",
44
+ }),
45
+ isDefault: t.Optional(t.Boolean()),
46
+ stages: t.Array(t.Object({
47
+ key: t.String({ minLength: 1, maxLength: 100, pattern: "\\S" }),
48
+ name: t.String({ minLength: 1, maxLength: 200, pattern: "\\S" }),
49
+ position: t.Integer({ minimum: 0 }),
50
+ outcome: t.Optional(enumSchema("Stage outcome", CRM_PIPELINE_OUTCOME)),
51
+ defaultProbability: t.Optional(probabilitySchema),
52
+ }, { additionalProperties: false }), { minItems: 1 }),
53
+ }, STRICT_OBJECT),
54
+ async execute(input, ctx) {
55
+ const { stages } = input;
56
+ if (new Set(stages.map((s) => s.key)).size !== stages.length) {
57
+ throw new Error("Duplicate pipeline stage key");
58
+ }
59
+ if (new Set(stages.map((s) => s.position)).size !== stages.length) {
60
+ throw new Error("Duplicate pipeline stage position");
61
+ }
62
+ const body = await request(ctx, `${CRM_BASE}/pipelines`, { method: "POST", body: JSON.stringify(input) });
63
+ return body.pipeline;
64
+ },
65
+ });
66
+ }
67
+ export function registerOpportunityActions(rl) {
68
+ rl.registerAction("opportunity.list", {
69
+ access: "read",
70
+ description: "List the first page of CRM opportunities.",
71
+ inputSchema: t.Object(opportunityListFields, STRICT_OBJECT),
72
+ async execute(input, ctx) {
73
+ const body = await request(ctx, withQuery(`${CRM_BASE}/opportunities`, listParams(input)));
74
+ return body.opportunities;
75
+ },
76
+ });
77
+ rl.registerAction("opportunity.listPage", {
78
+ access: "read",
79
+ description: "List a cursor-paginated page of CRM opportunities.",
80
+ inputSchema: t.Object(opportunityListFields, STRICT_OBJECT),
81
+ async execute(input, ctx) {
82
+ return request(ctx, withQuery(`${CRM_BASE}/opportunities`, listParams(input)));
83
+ },
84
+ });
85
+ rl.registerAction("opportunity.get", {
86
+ access: "read",
87
+ description: "Get a CRM opportunity by record ID.",
88
+ inputSchema: t.Object({ id: idSchema("Opportunity record ID") }, STRICT_OBJECT),
89
+ async execute(input, ctx) {
90
+ const { id } = input;
91
+ const body = await request(ctx, `${CRM_BASE}/opportunities/${pathSegment(id)}`);
92
+ return body.opportunity;
93
+ },
94
+ });
95
+ rl.registerAction("opportunity.create", {
96
+ access: "write",
97
+ description: "Create a CRM opportunity in a pipeline stage. accountId may be omitted while attribution is unresolved.",
98
+ inputSchema: t.Object({
99
+ accountId: t.Optional(idSchema("Account record ID")),
100
+ pipelineId: idSchema("Pipeline ID"),
101
+ stageId: idSchema("Stage ID (must belong to the pipeline)"),
102
+ title: t.String({
103
+ minLength: 1,
104
+ maxLength: 500,
105
+ pattern: "\\S",
106
+ description: "Opportunity title",
107
+ }),
108
+ amount: t.Optional(t.Number({ minimum: 0 })),
109
+ currency: t.Optional(t.String({ minLength: 1, maxLength: 10, pattern: "\\S" })),
110
+ probability: t.Optional(probabilitySchema),
111
+ expectedCloseAt: t.Optional(timestampSchema("ISO-8601 expected close timestamp")),
112
+ nextStep: t.Optional(t.String({ maxLength: 50_000 })),
113
+ closeReason: t.Optional(t.String({ maxLength: 10_000 })),
114
+ ...createRecordFields(),
115
+ }, STRICT_OBJECT),
116
+ async execute(input, ctx) {
117
+ const body = await request(ctx, `${CRM_BASE}/opportunities`, { method: "POST", body: JSON.stringify(input) });
118
+ return body.opportunity;
119
+ },
120
+ });
121
+ rl.registerAction("opportunity.update", {
122
+ access: "write",
123
+ description: "Update or archive a CRM opportunity, including moving it to another stage of its pipeline.",
124
+ inputSchema: t.Object({
125
+ id: idSchema("Opportunity record ID"),
126
+ stageId: t.Optional(idSchema("Stage ID within the same pipeline")),
127
+ title: t.Optional(t.String({ minLength: 1, maxLength: 500, pattern: "\\S" })),
128
+ amount: t.Optional(t.Union([t.Number({ minimum: 0 }), t.Null()])),
129
+ currency: t.Optional(t.Union([
130
+ t.String({ minLength: 1, maxLength: 10, pattern: "\\S" }),
131
+ t.Null(),
132
+ ])),
133
+ probability: t.Optional(t.Union([probabilitySchema, t.Null()])),
134
+ expectedCloseAt: nullableTimestamp("ISO-8601 expected close timestamp"),
135
+ nextStep: t.Optional(t.Union([t.String({ maxLength: 50_000 }), t.Null()])),
136
+ closeReason: t.Optional(t.Union([t.String({ maxLength: 10_000 }), t.Null()])),
137
+ ...updateRecordFields(),
138
+ }, STRICT_UPDATE_OBJECT),
139
+ async execute(input, ctx) {
140
+ const { id, ...patch } = input;
141
+ const body = await request(ctx, `${CRM_BASE}/opportunities/${pathSegment(id)}`, { method: "PATCH", body: JSON.stringify(patch) });
142
+ return body.opportunity;
143
+ },
144
+ });
145
+ }
@@ -0,0 +1,117 @@
1
+ import * as t from "typebox";
2
+ import { assertTimeOrder, CRM_BASE, CRM_IDENTITY_KIND, createRecordFields, enumSchema, idSchema, listParams, paginationFields, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, updateRecordFields, withQuery, } from "./shared.js";
3
+ const personListFields = {
4
+ search: t.Optional(t.String({ minLength: 1, pattern: "\\S", description: "Name search" })),
5
+ accountId: t.Optional(idSchema("Filter by affiliated account record ID")),
6
+ includeArchived: t.Optional(t.Boolean()),
7
+ ...paginationFields(),
8
+ };
9
+ function identitySchema() {
10
+ return t.Object({
11
+ kind: enumSchema("Identity kind", CRM_IDENTITY_KIND),
12
+ value: t.String({
13
+ minLength: 1,
14
+ maxLength: 1_000,
15
+ pattern: "\\S",
16
+ description: "Identity value, e.g. an email address",
17
+ }),
18
+ isPrimary: t.Optional(t.Boolean()),
19
+ }, { additionalProperties: false });
20
+ }
21
+ function affiliationSchema() {
22
+ return t.Object({
23
+ accountId: idSchema("Account record ID"),
24
+ title: t.Optional(t.String({ minLength: 1, maxLength: 300, pattern: "\\S" })),
25
+ role: t.Optional(t.String({ minLength: 1, maxLength: 200, pattern: "\\S" })),
26
+ isPrimary: t.Optional(t.Boolean()),
27
+ startedAt: t.Optional(timestampSchema("ISO-8601 start timestamp")),
28
+ endedAt: t.Optional(timestampSchema("ISO-8601 end timestamp")),
29
+ }, { additionalProperties: false });
30
+ }
31
+ function assertPersonDetails(input) {
32
+ const primaryKinds = new Set();
33
+ for (const identity of input.identities ?? []) {
34
+ if (!identity.isPrimary)
35
+ continue;
36
+ if (primaryKinds.has(identity.kind)) {
37
+ throw new Error(`A person may have only one primary ${identity.kind} identity`);
38
+ }
39
+ primaryKinds.add(identity.kind);
40
+ }
41
+ const accounts = input.accounts ?? [];
42
+ if (accounts.filter((a) => a.isPrimary && !a.endedAt).length > 1) {
43
+ throw new Error("A person may have only one current primary account");
44
+ }
45
+ if (new Set(accounts.map((a) => a.accountId)).size !== accounts.length) {
46
+ throw new Error("Duplicate account affiliation");
47
+ }
48
+ for (const account of accounts) {
49
+ assertTimeOrder(account.startedAt, account.endedAt, "Affiliation endedAt must not precede startedAt");
50
+ }
51
+ }
52
+ export function registerPersonActions(rl) {
53
+ rl.registerAction("person.list", {
54
+ access: "read",
55
+ description: "List the first page of CRM people.",
56
+ inputSchema: t.Object(personListFields, STRICT_OBJECT),
57
+ async execute(input, ctx) {
58
+ const body = await request(ctx, withQuery(`${CRM_BASE}/people`, listParams(input)));
59
+ return body.people;
60
+ },
61
+ });
62
+ rl.registerAction("person.listPage", {
63
+ access: "read",
64
+ description: "List a cursor-paginated page of CRM people.",
65
+ inputSchema: t.Object(personListFields, STRICT_OBJECT),
66
+ async execute(input, ctx) {
67
+ return request(ctx, withQuery(`${CRM_BASE}/people`, listParams(input)));
68
+ },
69
+ });
70
+ rl.registerAction("person.get", {
71
+ access: "read",
72
+ description: "Get a CRM person with identities and account affiliations by record ID.",
73
+ inputSchema: t.Object({ id: idSchema("Person record ID") }, STRICT_OBJECT),
74
+ async execute(input, ctx) {
75
+ const { id } = input;
76
+ const body = await request(ctx, `${CRM_BASE}/people/${pathSegment(id)}`);
77
+ return body.person;
78
+ },
79
+ });
80
+ rl.registerAction("person.create", {
81
+ access: "write",
82
+ description: "Create a CRM person with normalized identities (email, phone, WhatsApp, LinkedIn) and account affiliations.",
83
+ inputSchema: t.Object({
84
+ name: t.String({
85
+ minLength: 1,
86
+ maxLength: 300,
87
+ pattern: "\\S",
88
+ description: "Person name",
89
+ }),
90
+ identities: t.Optional(t.Array(identitySchema())),
91
+ accounts: t.Optional(t.Array(affiliationSchema())),
92
+ ...createRecordFields(),
93
+ }, STRICT_OBJECT),
94
+ async execute(input, ctx) {
95
+ assertPersonDetails(input);
96
+ const body = await request(ctx, `${CRM_BASE}/people`, { method: "POST", body: JSON.stringify(input) });
97
+ return body.person;
98
+ },
99
+ });
100
+ rl.registerAction("person.update", {
101
+ access: "write",
102
+ description: "Update or archive a CRM person. Supplied identities and accounts replace the existing sets.",
103
+ inputSchema: t.Object({
104
+ id: idSchema("Person record ID"),
105
+ name: t.Optional(t.String({ minLength: 1, maxLength: 300, pattern: "\\S" })),
106
+ identities: t.Optional(t.Array(identitySchema())),
107
+ accounts: t.Optional(t.Array(affiliationSchema())),
108
+ ...updateRecordFields(),
109
+ }, STRICT_UPDATE_OBJECT),
110
+ async execute(input, ctx) {
111
+ const { id, ...patch } = input;
112
+ assertPersonDetails(patch);
113
+ const body = await request(ctx, `${CRM_BASE}/people/${pathSegment(id)}`, { method: "PATCH", body: JSON.stringify(patch) });
114
+ return body.person;
115
+ },
116
+ });
117
+ }
@@ -0,0 +1,80 @@
1
+ import * as t from "typebox";
2
+ import { CRM_BASE, CRM_PROPERTY_DATA_TYPE, enumSchema, idSchema, listParams, pathSegment, recordTypeSchema, request, STRICT_OBJECT, withQuery, } from "./shared.js";
3
+ export function registerRecordActions(rl) {
4
+ rl.registerAction("record.changeEvents", {
5
+ access: "read",
6
+ description: "List the immutable change-event history (created/updated snapshots) for a CRM record.",
7
+ inputSchema: t.Object({ recordId: idSchema("CRM record ID") }, STRICT_OBJECT),
8
+ async execute(input, ctx) {
9
+ const { recordId } = input;
10
+ const body = await request(ctx, `${CRM_BASE}/records/${pathSegment(recordId)}/change-events`);
11
+ return body.changeEvents;
12
+ },
13
+ });
14
+ rl.registerAction("propertyDefinition.list", {
15
+ access: "read",
16
+ description: "List governed custom-property definitions, optionally for one record type.",
17
+ inputSchema: t.Object({ recordType: t.Optional(recordTypeSchema()) }, STRICT_OBJECT),
18
+ async execute(input, ctx) {
19
+ const body = await request(ctx, withQuery(`${CRM_BASE}/property-definitions`, listParams(input)));
20
+ return body.propertyDefinitions;
21
+ },
22
+ });
23
+ rl.registerAction("propertyDefinition.create", {
24
+ access: "write",
25
+ description: "Define a governed custom property for a record type. Requires a CRM admin grant. Enum types require options.",
26
+ inputSchema: t.Object({
27
+ recordType: recordTypeSchema(),
28
+ key: t.String({
29
+ maxLength: 100,
30
+ pattern: "^[a-z][a-z0-9_]*$",
31
+ description: "snake_case property key",
32
+ }),
33
+ label: t.String({
34
+ minLength: 1,
35
+ maxLength: 200,
36
+ pattern: "\\S",
37
+ description: "Human-readable label",
38
+ }),
39
+ dataType: enumSchema("Property data type", CRM_PROPERTY_DATA_TYPE),
40
+ options: t.Optional(t.Array(t.String({ minLength: 1, maxLength: 500, pattern: "\\S" }), {
41
+ description: "Allowed values for enum and multi_enum types",
42
+ })),
43
+ validation: t.Optional(t.Object({
44
+ minLength: t.Optional(t.Integer({ minimum: 0 })),
45
+ maxLength: t.Optional(t.Integer({ minimum: 0 })),
46
+ minimum: t.Optional(t.Number()),
47
+ maximum: t.Optional(t.Number()),
48
+ pattern: t.Optional(t.String({ maxLength: 1_000 })),
49
+ }, { additionalProperties: false })),
50
+ filterable: t.Optional(t.Boolean()),
51
+ }, STRICT_OBJECT),
52
+ async execute(input, ctx) {
53
+ const fields = input;
54
+ const isEnum = fields.dataType === "enum" || fields.dataType === "multi_enum";
55
+ const options = fields.options ?? [];
56
+ if (isEnum && options.length === 0) {
57
+ throw new Error("Enum property definitions require options");
58
+ }
59
+ if (!isEnum && options.length > 0) {
60
+ throw new Error("Options are only allowed on enum property types");
61
+ }
62
+ if (new Set(options).size !== options.length) {
63
+ throw new Error("Duplicate property option");
64
+ }
65
+ if (fields.validation?.pattern) {
66
+ try {
67
+ new RegExp(fields.validation.pattern);
68
+ }
69
+ catch {
70
+ throw new Error("Property validation pattern is not a valid regex");
71
+ }
72
+ }
73
+ const body = await request(ctx, `${CRM_BASE}/property-definitions`, {
74
+ method: "POST",
75
+ body: JSON.stringify(input),
76
+ });
77
+ return body.propertyDefinition;
78
+ },
79
+ });
80
+ }
@@ -0,0 +1,122 @@
1
+ import * as t from "typebox";
2
+ import { cursorSchema, enumSchema, idSchema, timestampSchema, } from "../../_shared/shiftCloud.js";
3
+ export { enumSchema, idSchema, listParams, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, withQuery, } from "../../_shared/shiftCloud.js";
4
+ export const CRM_BASE = "/v1/crm";
5
+ export const CRM_ACCESS_ROLE = ["user", "admin"];
6
+ export const CRM_RECORD_TYPE = [
7
+ "account",
8
+ "person",
9
+ "opportunity",
10
+ "activity",
11
+ "task",
12
+ ];
13
+ export const CRM_IDENTITY_KIND = [
14
+ "email",
15
+ "phone",
16
+ "whatsapp",
17
+ "linkedin",
18
+ "other",
19
+ ];
20
+ export const CRM_PIPELINE_OUTCOME = ["open", "won", "lost"];
21
+ export const CRM_TASK_STATUS = [
22
+ "todo",
23
+ "in_progress",
24
+ "blocked",
25
+ "done",
26
+ "cancelled",
27
+ ];
28
+ export const CRM_TASK_PRIORITY = ["low", "medium", "high", "urgent"];
29
+ export const CRM_ACTIVITY_DIRECTION = [
30
+ "inbound",
31
+ "outbound",
32
+ "internal",
33
+ ];
34
+ export const CRM_ACTIVITY_STATUS = [
35
+ "planned",
36
+ "completed",
37
+ "cancelled",
38
+ ];
39
+ export const CRM_PROPERTY_DATA_TYPE = [
40
+ "string",
41
+ "number",
42
+ "boolean",
43
+ "date",
44
+ "datetime",
45
+ "enum",
46
+ "multi_enum",
47
+ "json",
48
+ ];
49
+ function propertiesSchema(description) {
50
+ return t.Record(t.String(), t.Unknown(), { description });
51
+ }
52
+ function externalRefSchema() {
53
+ return t.Object({
54
+ sourceSystem: t.String({
55
+ minLength: 1,
56
+ maxLength: 100,
57
+ pattern: "\\S",
58
+ description: "Upstream system name, e.g. hubspot",
59
+ }),
60
+ sourceId: t.String({
61
+ minLength: 1,
62
+ maxLength: 500,
63
+ pattern: "\\S",
64
+ description: "Stable ID in the upstream system",
65
+ }),
66
+ sourceUrl: t.Optional(t.String({ minLength: 1, maxLength: 2_000 })),
67
+ attributes: t.Optional(propertiesSchema("Source-specific attributes")),
68
+ }, { additionalProperties: false });
69
+ }
70
+ function relationshipSchema() {
71
+ return t.Object({
72
+ toRecordId: idSchema("Target CRM record ID"),
73
+ relationshipType: t.String({
74
+ minLength: 1,
75
+ maxLength: 100,
76
+ pattern: "\\S",
77
+ description: "Relationship type, e.g. participant, attendee, subject",
78
+ }),
79
+ role: t.Optional(t.String({ minLength: 1, maxLength: 200, pattern: "\\S" })),
80
+ attributes: t.Optional(propertiesSchema("Governed link attributes")),
81
+ }, { additionalProperties: false });
82
+ }
83
+ /** Shared create-time record fields (owner, custom properties, links). */
84
+ export function createRecordFields() {
85
+ return {
86
+ ownerId: t.Optional(idSchema("Owning Shift user ID")),
87
+ customProperties: t.Optional(propertiesSchema("Governed custom properties; every key must have a property definition")),
88
+ externalRefs: t.Optional(t.Array(externalRefSchema())),
89
+ relationships: t.Optional(t.Array(relationshipSchema())),
90
+ };
91
+ }
92
+ /** Shared update-time record fields (tri-state PATCH semantics). */
93
+ export function updateRecordFields() {
94
+ return {
95
+ ownerId: t.Optional(t.Union([idSchema("Owning Shift user ID"), t.Null()])),
96
+ customProperties: t.Optional(propertiesSchema("Replacement governed custom properties")),
97
+ externalRefs: t.Optional(t.Array(externalRefSchema())),
98
+ relationships: t.Optional(t.Array(relationshipSchema())),
99
+ archived: t.Optional(t.Boolean({ description: "Archive or restore" })),
100
+ };
101
+ }
102
+ export function paginationFields(maxLimit = 200) {
103
+ return {
104
+ cursor: t.Optional(cursorSchema()),
105
+ limit: t.Optional(t.Integer({
106
+ minimum: 1,
107
+ maximum: maxLimit,
108
+ description: "Max results, default 50",
109
+ })),
110
+ };
111
+ }
112
+ export function recordTypeSchema(description = "CRM record type") {
113
+ return enumSchema(description, CRM_RECORD_TYPE);
114
+ }
115
+ export function assertTimeOrder(start, end, message) {
116
+ if (typeof start === "string" && typeof end === "string" && start > end) {
117
+ throw new Error(message);
118
+ }
119
+ }
120
+ export function nullableTimestamp(description) {
121
+ return t.Optional(t.Union([timestampSchema(description), t.Null()]));
122
+ }
@@ -0,0 +1,79 @@
1
+ import * as t from "typebox";
2
+ import { CRM_BASE, CRM_TASK_PRIORITY, CRM_TASK_STATUS, createRecordFields, enumSchema, idSchema, listParams, nullableTimestamp, paginationFields, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, updateRecordFields, withQuery, } from "./shared.js";
3
+ const taskListFields = {
4
+ linkedRecordId: t.Optional(idSchema("Filter by linked CRM record ID")),
5
+ status: t.Optional(enumSchema("Task status", CRM_TASK_STATUS)),
6
+ includeArchived: t.Optional(t.Boolean()),
7
+ ...paginationFields(),
8
+ };
9
+ export function registerTaskActions(rl) {
10
+ rl.registerAction("task.list", {
11
+ access: "read",
12
+ description: "List the first page of CRM tasks.",
13
+ inputSchema: t.Object(taskListFields, STRICT_OBJECT),
14
+ async execute(input, ctx) {
15
+ const body = await request(ctx, withQuery(`${CRM_BASE}/tasks`, listParams(input)));
16
+ return body.tasks;
17
+ },
18
+ });
19
+ rl.registerAction("task.listPage", {
20
+ access: "read",
21
+ description: "List a cursor-paginated page of CRM tasks.",
22
+ inputSchema: t.Object(taskListFields, STRICT_OBJECT),
23
+ async execute(input, ctx) {
24
+ return request(ctx, withQuery(`${CRM_BASE}/tasks`, listParams(input)));
25
+ },
26
+ });
27
+ rl.registerAction("task.get", {
28
+ access: "read",
29
+ description: "Get a CRM task by record ID.",
30
+ inputSchema: t.Object({ id: idSchema("Task record ID") }, STRICT_OBJECT),
31
+ async execute(input, ctx) {
32
+ const { id } = input;
33
+ const body = await request(ctx, `${CRM_BASE}/tasks/${pathSegment(id)}`);
34
+ return body.task;
35
+ },
36
+ });
37
+ rl.registerAction("task.create", {
38
+ access: "write",
39
+ description: "Create a CRM follow-up task, optionally linked to records via relationships.",
40
+ inputSchema: t.Object({
41
+ title: t.String({
42
+ minLength: 1,
43
+ maxLength: 500,
44
+ pattern: "\\S",
45
+ description: "Task title",
46
+ }),
47
+ status: t.Optional(enumSchema("Task status", CRM_TASK_STATUS)),
48
+ priority: t.Optional(enumSchema("Task priority", CRM_TASK_PRIORITY)),
49
+ dueAt: t.Optional(timestampSchema("ISO-8601 due timestamp")),
50
+ assignedTo: t.Optional(idSchema("Assigned Shift user ID")),
51
+ ...createRecordFields(),
52
+ }, STRICT_OBJECT),
53
+ async execute(input, ctx) {
54
+ const body = await request(ctx, `${CRM_BASE}/tasks`, {
55
+ method: "POST",
56
+ body: JSON.stringify(input),
57
+ });
58
+ return body.task;
59
+ },
60
+ });
61
+ rl.registerAction("task.update", {
62
+ access: "write",
63
+ description: "Update or archive a CRM task: title, status, priority, due date, or assignee.",
64
+ inputSchema: t.Object({
65
+ id: idSchema("Task record ID"),
66
+ title: t.Optional(t.String({ minLength: 1, maxLength: 500, pattern: "\\S" })),
67
+ status: t.Optional(enumSchema("Task status", CRM_TASK_STATUS)),
68
+ priority: t.Optional(enumSchema("Task priority", CRM_TASK_PRIORITY)),
69
+ dueAt: nullableTimestamp("ISO-8601 due timestamp"),
70
+ assignedTo: t.Optional(t.Union([idSchema("Assigned Shift user ID"), t.Null()])),
71
+ ...updateRecordFields(),
72
+ }, STRICT_UPDATE_OBJECT),
73
+ async execute(input, ctx) {
74
+ const { id, ...patch } = input;
75
+ const body = await request(ctx, `${CRM_BASE}/tasks/${pathSegment(id)}`, { method: "PATCH", body: JSON.stringify(patch) });
76
+ return body.task;
77
+ },
78
+ });
79
+ }
@@ -1,50 +1,5 @@
1
- import * as t from "typebox";
2
- export const STRICT_OBJECT = { additionalProperties: false };
3
- export const STRICT_UPDATE_OBJECT = {
4
- additionalProperties: false,
5
- minProperties: 2,
6
- };
7
- export function idSchema(description) {
8
- return t.String({ minLength: 1, pattern: "\\S", description });
9
- }
10
- export function cursorSchema(description = "Next-page cursor") {
11
- return t.String({ minLength: 1, maxLength: 512, description });
12
- }
13
- export function timestampSchema(description) {
14
- return t.String({
15
- format: "date-time",
16
- pattern: "Z$",
17
- description,
18
- });
19
- }
20
- const SHIFT_LABS_API_URL = "https://d1ood6y5zobtne.cloudfront.net";
21
- export function baseUrl() {
22
- return `${SHIFT_LABS_API_URL}/`;
23
- }
24
- export async function request(ctx, path, init = {}) {
25
- const headers = new Headers(init.headers);
26
- const apiKey = ctx.connection.config.apiKey;
27
- if (typeof apiKey !== "string" || !apiKey) {
28
- throw new Error("Shift Labs apiKey is required");
29
- }
30
- headers.set("authorization", `Bearer ${apiKey}`);
31
- if (init.body && !headers.has("content-type")) {
32
- headers.set("content-type", "application/json");
33
- }
34
- const response = await fetch(new URL(path, baseUrl()), {
35
- ...init,
36
- headers,
37
- });
38
- if (!response.ok) {
39
- throw new Error(`Shift Labs API error ${response.status}: ${await response.text()}`);
40
- }
41
- if (response.status === 204)
42
- return undefined;
43
- return (await response.json());
44
- }
45
- export function pathSegment(value) {
46
- return encodeURIComponent(value);
47
- }
1
+ import { baseUrl, pathSegment } from "../../_shared/shiftCloud.js";
2
+ export { baseUrl, cursorSchema, enumDescription, enumSchema, idSchema, pathSegment, request, STRICT_OBJECT, STRICT_UPDATE_OBJECT, timestampSchema, } from "../../_shared/shiftCloud.js";
48
3
  export function pageRenderUrl(organizationId, slug) {
49
4
  return new URL(`/pages/${pathSegment(organizationId)}/${pathSegment(slug)}`, baseUrl()).toString();
50
5
  }
@@ -94,9 +49,3 @@ export const PAGE_STATUS = ["draft", "published", "archived"];
94
49
  export const PAGE_VISIBILITY = ["org", "invited"];
95
50
  export const TRANSCRIPTION_LANGUAGE = ["auto", "en", "he"];
96
51
  export const TRANSCRIPT_FORMAT = ["txt", "srt", "vtt", "json"];
97
- export function enumDescription(name, values) {
98
- return `${name}: ${values.join(" | ")}`;
99
- }
100
- export function enumSchema(name, values) {
101
- return t.Union(values.map((value) => t.Literal(value)), { description: enumDescription(name, values) });
102
- }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "runline",
3
- "version": "0.14.1",
3
+ "version": "0.15.0",
4
4
  "description": "Code mode for agents — turn any API or command into a callable action",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",