@withloops.ai/contracts 0.0.0-stage → 0.2.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,46 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Every permission an app can ask for. One list for REST, MCP, events and LoopKit (skill §8 "one scope list").
4
+ * Only ever ADD to this list (versioning promise). `consent` is the plain-words line on the install sheet
5
+ * ("<App> can …"), sentence case.
6
+ */
7
+ export declare const SCOPES: {
8
+ readonly 'contacts:read': "See contacts: names, emails, phone numbers and their company";
9
+ readonly 'contacts:write': "Add and change contacts";
10
+ readonly 'companies:read': "See companies";
11
+ readonly 'companies:write': "Add and change companies";
12
+ readonly 'leads:read': "See leads";
13
+ readonly 'leads:write': "Add and change leads";
14
+ readonly 'deals:read': "See deals and the people on them";
15
+ readonly 'deals:write': "Add and change deals";
16
+ readonly 'notes:write': "Write notes on records, marked with the app’s name";
17
+ readonly 'actions:write': "Add Actions for people in your workspace";
18
+ readonly 'loops:read': "Read messages in loops the app is added to";
19
+ readonly 'loops:write': "Post in loops the app is added to, marked with the app’s name";
20
+ readonly 'calls:read': "See calls and their transcripts";
21
+ readonly 'meetings:read': "See meetings and their notes";
22
+ readonly 'webhooks:manage': "Send events from your workspace to the app";
23
+ };
24
+ export type Scope = keyof typeof SCOPES;
25
+ export declare const SCOPE_NAMES: [Scope, ...Scope[]];
26
+ export declare const ScopeSchema: z.ZodEnum<{
27
+ "contacts:read": "contacts:read";
28
+ "contacts:write": "contacts:write";
29
+ "companies:read": "companies:read";
30
+ "companies:write": "companies:write";
31
+ "leads:read": "leads:read";
32
+ "leads:write": "leads:write";
33
+ "deals:read": "deals:read";
34
+ "deals:write": "deals:write";
35
+ "notes:write": "notes:write";
36
+ "actions:write": "actions:write";
37
+ "loops:read": "loops:read";
38
+ "loops:write": "loops:write";
39
+ "calls:read": "calls:read";
40
+ "meetings:read": "meetings:read";
41
+ "webhooks:manage": "webhooks:manage";
42
+ }>;
43
+ /** The consent line for a scope, e.g. on the install sheet. */
44
+ export declare function consentLine(scope: Scope): string;
45
+ /** A `write` scope implies its `read` (asking for contacts:write shows both lines). */
46
+ export declare function expandScopes(scopes: readonly Scope[]): Scope[];
package/dist/scopes.js ADDED
@@ -0,0 +1,40 @@
1
+ import { z } from 'zod';
2
+ /**
3
+ * Every permission an app can ask for. One list for REST, MCP, events and LoopKit (skill §8 "one scope list").
4
+ * Only ever ADD to this list (versioning promise). `consent` is the plain-words line on the install sheet
5
+ * ("<App> can …"), sentence case.
6
+ */
7
+ export const SCOPES = {
8
+ 'contacts:read': 'See contacts: names, emails, phone numbers and their company',
9
+ 'contacts:write': 'Add and change contacts',
10
+ 'companies:read': 'See companies',
11
+ 'companies:write': 'Add and change companies',
12
+ 'leads:read': 'See leads',
13
+ 'leads:write': 'Add and change leads',
14
+ 'deals:read': 'See deals and the people on them',
15
+ 'deals:write': 'Add and change deals',
16
+ 'notes:write': 'Write notes on records, marked with the app’s name',
17
+ 'actions:write': 'Add Actions for people in your workspace',
18
+ 'loops:read': 'Read messages in loops the app is added to',
19
+ 'loops:write': 'Post in loops the app is added to, marked with the app’s name',
20
+ 'calls:read': 'See calls and their transcripts',
21
+ 'meetings:read': 'See meetings and their notes',
22
+ 'webhooks:manage': 'Send events from your workspace to the app',
23
+ };
24
+ export const SCOPE_NAMES = Object.keys(SCOPES);
25
+ export const ScopeSchema = z.enum(SCOPE_NAMES);
26
+ /** The consent line for a scope, e.g. on the install sheet. */
27
+ export function consentLine(scope) {
28
+ return SCOPES[scope];
29
+ }
30
+ /** A `write` scope implies its `read` (asking for contacts:write shows both lines). */
31
+ export function expandScopes(scopes) {
32
+ const out = new Set(scopes);
33
+ for (const s of scopes) {
34
+ const [res, op] = s.split(':');
35
+ const read = `${res}:read`;
36
+ if (op === 'write' && read in SCOPES)
37
+ out.add(read);
38
+ }
39
+ return SCOPE_NAMES.filter((s) => out.has(s));
40
+ }
@@ -0,0 +1,24 @@
1
+ {
2
+ "manifestVersion": 1,
3
+ "id": "acme-checks",
4
+ "name": "Acme Checks",
5
+ "version": "1.0.0",
6
+ "summary": "Background checks on your contacts, started from the record.",
7
+ "developer": { "name": "Acme", "email": "dev@acme-checks.com", "website": "https://acme-checks.com" },
8
+ "visibility": "public",
9
+ "scopes": ["contacts:read", "notes:write"],
10
+ "ui": {
11
+ "apiVersion": 1,
12
+ "slots": ["recordTab", "linkCard"],
13
+ "uses": ["contact.firstName", "contact.email"],
14
+ "does": ["record.note"],
15
+ "hears": ["contact.updated"],
16
+ "links": ["*.acme-checks.com/report/*"],
17
+ "settings": { "apiKey": { "type": "secret", "label": "Your Acme API key" }, "package": { "type": "choice", "label": "Package", "options": ["Basic", "Enhanced"] } },
18
+ "endpoints": {
19
+ "checkStatus": { "method": "GET", "url": "https://api.acme-checks.com/v1/checks/{email}", "sends": { "email": "contact.email" }, "returns": { "status": "enum:clear|pending|flagged", "date": "date" } },
20
+ "startCheck": { "method": "POST", "url": "https://api.acme-checks.com/v1/checks", "sends": { "name": "contact.firstName", "email": "contact.email", "package": "settings.package" }, "returns": { "id": "string", "status": "enum:pending" } },
21
+ "report": { "method": "GET", "url": "https://api.acme-checks.com/v1/reports/{id}", "sends": { "id": "link.path.id" }, "returns": { "status": "enum:clear|pending|flagged" } }
22
+ }
23
+ }
24
+ }
@@ -0,0 +1,114 @@
1
+ {
2
+ "manifestVersion": 1,
3
+ "id": "acme-crm",
4
+ "name": "Acme CRM",
5
+ "version": "0.1.0",
6
+ "summary": "Use Acme CRM as your CRM of record; Loops writes calls and notes to its timeline.",
7
+ "developer": {
8
+ "name": "Acme",
9
+ "email": "dev@acme-crm.com",
10
+ "website": "https://acme-crm.com"
11
+ },
12
+ "visibility": "public",
13
+ "scopes": [
14
+ "contacts:write",
15
+ "companies:write"
16
+ ],
17
+ "sync": {
18
+ "baseUrl": "https://api.acme-crm.com",
19
+ "auth": {
20
+ "type": "oauth2",
21
+ "authorizeUrl": "https://app.acme-crm.com/oauth/authorize",
22
+ "tokenUrl": "https://api.acme-crm.com/oauth/token",
23
+ "scopes": [
24
+ "contacts",
25
+ "companies"
26
+ ]
27
+ },
28
+ "objects": [
29
+ {
30
+ "theirs": "people",
31
+ "ours": "CONTACT",
32
+ "list": {
33
+ "path": "/v1/people",
34
+ "itemsPath": "data",
35
+ "cursorParam": "cursor",
36
+ "nextCursorPath": "next_cursor",
37
+ "updatedSinceParam": "updated_since"
38
+ },
39
+ "fields": [
40
+ {
41
+ "theirs": "first_name",
42
+ "ours": "firstName",
43
+ "owner": "theirs"
44
+ },
45
+ {
46
+ "theirs": "email",
47
+ "ours": "email",
48
+ "owner": "theirs"
49
+ }
50
+ ],
51
+ "match": [
52
+ "email"
53
+ ]
54
+ },
55
+ {
56
+ "theirs": "organizations",
57
+ "ours": "COMPANY",
58
+ "list": {
59
+ "path": "/v1/organizations",
60
+ "itemsPath": "data",
61
+ "cursorParam": "cursor",
62
+ "nextCursorPath": "next_cursor"
63
+ },
64
+ "fields": [
65
+ {
66
+ "theirs": "name",
67
+ "ours": "name",
68
+ "owner": "theirs"
69
+ },
70
+ {
71
+ "theirs": "domain",
72
+ "ours": "website",
73
+ "owner": "theirs"
74
+ }
75
+ ],
76
+ "match": [
77
+ "domain"
78
+ ]
79
+ }
80
+ ],
81
+ "links": [
82
+ {
83
+ "from": "CONTACT",
84
+ "to": "COMPANY",
85
+ "theirs": "organization_id"
86
+ }
87
+ ],
88
+ "changeFeed": {
89
+ "webhook": {
90
+ "signature": {
91
+ "scheme": "hmac-sha256",
92
+ "header": "X-Acme-Signature"
93
+ }
94
+ },
95
+ "polling": {
96
+ "everyMinutes": 15
97
+ }
98
+ },
99
+ "writeBack": {
100
+ "call": {
101
+ "method": "POST",
102
+ "path": "/v1/activities"
103
+ },
104
+ "note": {
105
+ "method": "POST",
106
+ "path": "/v1/notes"
107
+ }
108
+ },
109
+ "limits": {
110
+ "requestsPerSecond": 10,
111
+ "burst": 100
112
+ }
113
+ }
114
+ }
@@ -0,0 +1,14 @@
1
+ {
2
+ "manifestVersion": 1,
3
+ "id": "hello",
4
+ "name": "Hello",
5
+ "version": "0.1.0",
6
+ "summary": "Says hello on a contact.",
7
+ "developer": { "name": "Loops", "email": "developers@withloops.ai" },
8
+ "scopes": ["contacts:read"],
9
+ "ui": {
10
+ "apiVersion": 1,
11
+ "slots": ["recordPanel"],
12
+ "uses": ["contact.firstName"]
13
+ }
14
+ }
package/package.json CHANGED
@@ -1,6 +1,43 @@
1
1
  {
2
2
  "name": "@withloops.ai/contracts",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.2.0",
4
+ "description": "The Loops developer platform contracts (zod): app manifest v1, scopes, events, the /v1 API envelope and registry records. Everything else builds on these.",
5
+ "type": "module",
6
+ "license": "UNLICENSED",
7
+ "exports": {
8
+ ".": {
9
+ "types": "./dist/index.d.ts",
10
+ "import": "./dist/index.js"
11
+ }
12
+ },
13
+ "files": [
14
+ "dist",
15
+ "src",
16
+ "examples"
17
+ ],
18
+ "engines": {
19
+ "node": ">=22.18"
20
+ },
21
+ "scripts": {
22
+ "test": "node --test \"test/*.test.ts\"",
23
+ "typecheck": "tsc --noEmit",
24
+ "build": "tsc -p tsconfig.build.json",
25
+ "prepublishOnly": "npm run build"
26
+ },
27
+ "dependencies": {
28
+ "zod": "^4.3.6"
29
+ },
30
+ "devDependencies": {
31
+ "typescript": "^5.9.3",
32
+ "@types/node": "^24.0.0"
33
+ },
34
+ "homepage": "https://withloops.ai",
35
+ "keywords": [
36
+ "loops",
37
+ "withloops",
38
+ "sdk",
39
+ "contracts"
40
+ ],
41
+ "main": "./dist/index.js",
42
+ "types": "./dist/index.d.ts"
43
+ }
package/src/api.ts ADDED
@@ -0,0 +1,124 @@
1
+ import { z } from 'zod';
2
+ import { Timestamp } from './ids.ts';
3
+
4
+ /**
5
+ * The public REST API envelope: `api.withloops.ai/v1`. One envelope for every resource, cursor paging, one error shape.
6
+ * Only ever ADD fields (versioning promise). Hono + @hono/zod-openapi build the OpenAPI spec from these schemas.
7
+ */
8
+
9
+ export const API_VERSION = 'v1' as const;
10
+
11
+ // ── Errors ──────────────────────────────────────────────────────────────────────
12
+
13
+ /** Every error code the API returns, with its HTTP status. */
14
+ export const ERROR_CODES = {
15
+ bad_request: 400,
16
+ validation_failed: 422,
17
+ unauthorized: 401,
18
+ forbidden: 403,
19
+ missing_scope: 403,
20
+ not_found: 404,
21
+ conflict: 409,
22
+ rate_limited: 429,
23
+ internal: 500,
24
+ upstream_timeout: 504,
25
+ unavailable: 503,
26
+ } as const;
27
+ export type ErrorCode = keyof typeof ERROR_CODES;
28
+ export const ErrorCodeSchema = z.enum(Object.keys(ERROR_CODES) as [ErrorCode, ...ErrorCode[]]);
29
+
30
+ export const ApiError = z.object({
31
+ code: ErrorCodeSchema,
32
+ /** Plain words a developer can act on. Never a stack trace, never a secret. */
33
+ message: z.string(),
34
+ /** Field-level problems for validation_failed: { "email": "Not an email" }. */
35
+ fields: z.record(z.string(), z.string()).optional(),
36
+ /** The scope that was missing, for missing_scope. */
37
+ scope: z.string().optional(),
38
+ /** Echoed in the `x-request-id` header; quote it to support. */
39
+ requestId: z.string(),
40
+ });
41
+ export type ApiError = z.infer<typeof ApiError>;
42
+
43
+ export const ErrorEnvelope = z.object({ ok: z.literal(false), error: ApiError });
44
+ export type ErrorEnvelope = z.infer<typeof ErrorEnvelope>;
45
+
46
+ export function errorStatus(code: ErrorCode): number {
47
+ return ERROR_CODES[code];
48
+ }
49
+
50
+ // ── Success ─────────────────────────────────────────────────────────────────────
51
+
52
+ export const Page = z.object({
53
+ /** Pass as `cursor` to get the next page; null on the last page. */
54
+ nextCursor: z.string().nullable(),
55
+ hasMore: z.boolean(),
56
+ });
57
+ export type Page = z.infer<typeof Page>;
58
+
59
+ /** Single object: `{ ok: true, data }`. */
60
+ export function dataEnvelope<T extends z.ZodType>(data: T) {
61
+ return z.object({ ok: z.literal(true), data });
62
+ }
63
+
64
+ /** List: `{ ok: true, data: [...], page: { nextCursor, hasMore } }`. */
65
+ export function listEnvelope<T extends z.ZodType>(item: T) {
66
+ return z.object({ ok: z.literal(true), data: z.array(item), page: Page });
67
+ }
68
+
69
+ export type DataEnvelope<T> = { ok: true; data: T };
70
+ export type ListEnvelope<T> = { ok: true; data: T[]; page: Page };
71
+
72
+ // ── Paging query ────────────────────────────────────────────────────────────────
73
+
74
+ export const PAGE_LIMIT_DEFAULT = 25;
75
+ export const PAGE_LIMIT_MAX = 100;
76
+
77
+ export const ListQuery = z.object({
78
+ cursor: z.string().min(1).optional(),
79
+ limit: z.coerce.number().int().min(1).max(PAGE_LIMIT_MAX).default(PAGE_LIMIT_DEFAULT),
80
+ /** Only records changed at or after this time (sync and polling). */
81
+ updatedSince: Timestamp.optional(),
82
+ });
83
+ export type ListQuery = z.infer<typeof ListQuery>;
84
+
85
+ // ── Resources (L0: contacts) ────────────────────────────────────────────────────
86
+
87
+ /** A Loops contact as the public API shows it. camelCase, ids as strings, dates ISO. */
88
+ export const Contact = z.object({
89
+ id: z.string(),
90
+ firstName: z.string().nullable(),
91
+ lastName: z.string().nullable(),
92
+ email: z.email().nullable(),
93
+ phone: z.string().nullable(),
94
+ jobTitle: z.string().nullable(),
95
+ companyId: z.string().nullable(),
96
+ ownerId: z.string().nullable(),
97
+ createdAt: Timestamp,
98
+ updatedAt: Timestamp,
99
+ });
100
+ export type Contact = z.infer<typeof Contact>;
101
+
102
+ export const ContactCreate = z
103
+ .object({
104
+ firstName: z.string().max(120).optional(),
105
+ lastName: z.string().max(120).optional(),
106
+ email: z.email().optional(),
107
+ phone: z.string().max(40).optional(),
108
+ jobTitle: z.string().max(200).optional(),
109
+ companyId: z.string().optional(),
110
+ ownerId: z.string().optional(),
111
+ })
112
+ .refine((c) => c.firstName || c.lastName || c.email, 'Give a name or an email');
113
+ export type ContactCreate = z.infer<typeof ContactCreate>;
114
+
115
+ export const ContactUpdate = z.object({
116
+ firstName: z.string().max(120).nullable().optional(),
117
+ lastName: z.string().max(120).nullable().optional(),
118
+ email: z.email().nullable().optional(),
119
+ phone: z.string().max(40).nullable().optional(),
120
+ jobTitle: z.string().max(200).nullable().optional(),
121
+ companyId: z.string().nullable().optional(),
122
+ ownerId: z.string().nullable().optional(),
123
+ });
124
+ export type ContactUpdate = z.infer<typeof ContactUpdate>;
package/src/events.ts ADDED
@@ -0,0 +1,65 @@
1
+ import { z } from 'zod';
2
+ import { AppId, EventId, InstallId, Timestamp, UserId, WorkspaceId } from './ids.ts';
3
+ import type { Scope } from './scopes.ts';
4
+
5
+ /**
6
+ * Every event an app can hear (LoopKit `hears`, webhooks out, the engine's queues). Each names the scope that lets an app
7
+ * hear it. Only ever ADD here (versioning promise).
8
+ */
9
+ export const EVENTS = {
10
+ 'contact.created': 'contacts:read',
11
+ 'contact.updated': 'contacts:read',
12
+ 'contact.deleted': 'contacts:read',
13
+ 'company.created': 'companies:read',
14
+ 'company.updated': 'companies:read',
15
+ 'company.deleted': 'companies:read',
16
+ 'lead.created': 'leads:read',
17
+ 'lead.updated': 'leads:read',
18
+ 'lead.deleted': 'leads:read',
19
+ 'deal.created': 'deals:read',
20
+ 'deal.updated': 'deals:read',
21
+ 'deal.deleted': 'deals:read',
22
+ 'loop.message': 'loops:read',
23
+ 'loop.call.ended': 'calls:read',
24
+ 'meeting.ended': 'meetings:read',
25
+ 'app.installed': null,
26
+ 'app.uninstalled': null,
27
+ } as const satisfies Record<string, Scope | null>;
28
+
29
+ export type EventType = keyof typeof EVENTS;
30
+ export const EVENT_TYPES = Object.keys(EVENTS) as [EventType, ...EventType[]];
31
+ export const EventTypeSchema = z.enum(EVENT_TYPES);
32
+
33
+ /** The scope needed to hear an event; null = every installed app hears it (about itself only). */
34
+ export function scopeForEvent(type: EventType): Scope | null {
35
+ return EVENTS[type];
36
+ }
37
+
38
+ /** Who caused a change. Apps never hear their own echo (`causedBy.kind === 'app'` and same app id). */
39
+ export const CausedBy = z.discriminatedUnion('kind', [
40
+ z.object({ kind: z.literal('user'), id: UserId }),
41
+ z.object({ kind: z.literal('app'), id: AppId }),
42
+ z.object({ kind: z.literal('system') }),
43
+ ]);
44
+ export type CausedBy = z.infer<typeof CausedBy>;
45
+
46
+ /**
47
+ * The one event envelope. `data` carries ids plus only the fields the app was granted; the engine trims it per install,
48
+ * so `data` is open here and each consumer narrows it.
49
+ */
50
+ export const EventEnvelope = z.object({
51
+ id: EventId,
52
+ type: EventTypeSchema,
53
+ version: z.literal(1),
54
+ occurredAt: Timestamp,
55
+ workspaceId: WorkspaceId,
56
+ installId: InstallId.optional(),
57
+ causedBy: CausedBy,
58
+ data: z.record(z.string(), z.unknown()),
59
+ });
60
+ export type EventEnvelope = z.infer<typeof EventEnvelope>;
61
+
62
+ /** True when an app should not get this event back (it caused it). */
63
+ export function isEcho(event: Pick<EventEnvelope, 'causedBy'>, appId: string): boolean {
64
+ return event.causedBy.kind === 'app' && event.causedBy.id === appId;
65
+ }
package/src/ids.ts ADDED
@@ -0,0 +1,39 @@
1
+ import { z } from 'zod';
2
+
3
+ /** An app's id: the slug chosen at `loops create`, unique across Loops, never changes. */
4
+ export const AppId = z
5
+ .string()
6
+ .regex(/^[a-z][a-z0-9-]{1,38}[a-z0-9]$/, 'App id: 3–40 characters, lowercase letters, digits and dashes, starting with a letter');
7
+ export type AppId = z.infer<typeof AppId>;
8
+
9
+ /** Semantic version of one app release (`1.0.0`, `1.2.0-beta.1`). */
10
+ export const SemVer = z
11
+ .string()
12
+ .regex(/^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-[0-9A-Za-z.-]+)?$/, 'Version must look like 1.0.0');
13
+ export type SemVer = z.infer<typeof SemVer>;
14
+
15
+ /** A Loops workspace (the customer side; Vista tenant id underneath). */
16
+ export const WorkspaceId = z.string().min(1).max(64);
17
+ export type WorkspaceId = z.infer<typeof WorkspaceId>;
18
+
19
+ /** A person in a workspace (Vista user id underneath). */
20
+ export const UserId = z.string().min(1).max(64);
21
+ export type UserId = z.infer<typeof UserId>;
22
+
23
+ /** Opaque ids minted by the registry. Prefix shows the kind at a glance in logs. */
24
+ export const InstallId = z.string().regex(/^ins_[A-Za-z0-9]{8,40}$/);
25
+ export type InstallId = z.infer<typeof InstallId>;
26
+
27
+ export const VersionId = z.string().regex(/^ver_[A-Za-z0-9]{8,40}$/);
28
+ export type VersionId = z.infer<typeof VersionId>;
29
+
30
+ export const EventId = z.string().regex(/^evt_[A-Za-z0-9]{8,40}$/);
31
+ export type EventId = z.infer<typeof EventId>;
32
+
33
+ /** ISO 8601 timestamp with offset, e.g. 2026-11-11T09:30:00Z. */
34
+ export const Timestamp = z.iso.datetime({ offset: true });
35
+ export type Timestamp = z.infer<typeof Timestamp>;
36
+
37
+ /** Public https URL. http is refused everywhere in the platform. */
38
+ export const HttpsUrl = z.url({ protocol: /^https$/ });
39
+ export type HttpsUrl = z.infer<typeof HttpsUrl>;
package/src/index.ts ADDED
@@ -0,0 +1,6 @@
1
+ export * from './ids.ts';
2
+ export * from './scopes.ts';
3
+ export * from './events.ts';
4
+ export * from './manifest.ts';
5
+ export * from './api.ts';
6
+ export * from './registry.ts';