@keystrokehq/skills 0.0.1
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.
- package/AGENTS-blurb.md +123 -0
- package/LICENSE +21 -0
- package/README.md +63 -0
- package/keystroke-agent-authoring/SKILL.md +225 -0
- package/keystroke-agent-authoring/evals/evals.json +29 -0
- package/keystroke-agent-authoring/references/messaging-gateways.md +242 -0
- package/keystroke-agent-authoring/references/patterns.md +417 -0
- package/keystroke-agent-authoring/references/prebuilt-integrations.md +879 -0
- package/keystroke-agent-authoring/references/sandbox-and-mcp.md +214 -0
- package/keystroke-agent-authoring/references/source-map.md +182 -0
- package/keystroke-agent-authoring/references/testing.md +85 -0
- package/keystroke-cli-workspace/SKILL.md +93 -0
- package/keystroke-cli-workspace/evals/evals.json +23 -0
- package/keystroke-cli-workspace/references/command-map.md +50 -0
- package/keystroke-cli-workspace/references/credentials-and-connect.md +79 -0
- package/keystroke-cli-workspace/references/project-lifecycle.md +85 -0
- package/keystroke-credential-binding/SKILL.md +509 -0
- package/keystroke-credential-binding/evals/evals.json +29 -0
- package/keystroke-credential-binding/references/cli.md +85 -0
- package/keystroke-credential-binding/references/patterns.md +878 -0
- package/keystroke-credential-binding/references/source-map.md +69 -0
- package/keystroke-data-toolkit/SKILL.md +59 -0
- package/keystroke-data-toolkit/evals/evals.json +23 -0
- package/keystroke-data-toolkit/references/usage.md +79 -0
- package/keystroke-task-authoring/SKILL.md +124 -0
- package/keystroke-task-authoring/evals/evals.json +23 -0
- package/keystroke-task-authoring/references/patterns.md +132 -0
- package/keystroke-task-authoring/references/source-map.md +61 -0
- package/keystroke-trigger-authoring/SKILL.md +189 -0
- package/keystroke-trigger-authoring/evals/evals.json +29 -0
- package/keystroke-trigger-authoring/references/patterns.md +265 -0
- package/keystroke-trigger-authoring/references/source-map.md +128 -0
- package/keystroke-trigger-authoring/references/testing.md +148 -0
- package/keystroke-workflow-as-tool-debugging/SKILL.md +52 -0
- package/keystroke-workflow-as-tool-debugging/evals/evals.json +23 -0
- package/keystroke-workflow-as-tool-debugging/references/playbook.md +77 -0
- package/keystroke-workflow-authoring/SKILL.md +234 -0
- package/keystroke-workflow-authoring/evals/evals.json +29 -0
- package/keystroke-workflow-authoring/references/patterns.md +265 -0
- package/keystroke-workflow-authoring/references/prebuilt-integrations.md +811 -0
- package/keystroke-workflow-authoring/references/runtime-helpers.md +264 -0
- package/keystroke-workflow-authoring/references/source-map.md +108 -0
- package/keystroke-workflow-authoring/references/testing.md +108 -0
- package/package.json +26 -0
|
@@ -0,0 +1,878 @@
|
|
|
1
|
+
# Credential Examples
|
|
2
|
+
|
|
3
|
+
Read this file when the user needs concrete examples for credential authoring or binding.
|
|
4
|
+
|
|
5
|
+
Some later snippets reuse `crmCredentials` or `lookupTicketTool` from earlier snippets in this file.
|
|
6
|
+
|
|
7
|
+
`Operation`, `Step`, and `Tool` are aliases for the same class. This reference shows both the workflow-oriented `Step` name and the agent-oriented `Tool` name so credential usage is clear in each context.
|
|
8
|
+
|
|
9
|
+
File layout reminder:
|
|
10
|
+
|
|
11
|
+
- keep each exported `CredentialSet` in its own `*.credential-set.ts` file
|
|
12
|
+
- steps, tools, agents, triggers, and MCP servers should each live in their own typed files
|
|
13
|
+
- when a snippet reuses `crmCredentials` or another primitive, import it from that primitive's own file
|
|
14
|
+
|
|
15
|
+
## Minimal `CredentialSet`
|
|
16
|
+
|
|
17
|
+
```ts
|
|
18
|
+
import { CredentialSet } from '@keystrokehq/core';
|
|
19
|
+
import { z } from 'zod';
|
|
20
|
+
|
|
21
|
+
export const crmCredentials = new CredentialSet({
|
|
22
|
+
id: 'crmApi',
|
|
23
|
+
auth: z.object({
|
|
24
|
+
apiKey: z.string(),
|
|
25
|
+
}),
|
|
26
|
+
});
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
This is the smallest valid credential set:
|
|
30
|
+
|
|
31
|
+
- `id` identifies the set
|
|
32
|
+
- `auth` defines the runtime values the primitive will read
|
|
33
|
+
|
|
34
|
+
## `name` and `description`
|
|
35
|
+
|
|
36
|
+
```ts
|
|
37
|
+
import { CredentialSet } from '@keystrokehq/core';
|
|
38
|
+
import { z } from 'zod';
|
|
39
|
+
|
|
40
|
+
export const namedCredentials = new CredentialSet({
|
|
41
|
+
id: 'billingApi',
|
|
42
|
+
name: 'Billing API',
|
|
43
|
+
description: 'Credentials for the billing backend.',
|
|
44
|
+
auth: z.object({
|
|
45
|
+
apiKey: z.string(),
|
|
46
|
+
accountId: z.string(),
|
|
47
|
+
}),
|
|
48
|
+
});
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## `stored` and `resolve`
|
|
52
|
+
|
|
53
|
+
```ts
|
|
54
|
+
import { CredentialSet } from '@keystrokehq/core';
|
|
55
|
+
import { z } from 'zod';
|
|
56
|
+
|
|
57
|
+
const refreshTokenSchema = z.object({
|
|
58
|
+
refreshToken: z.string(),
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
export const oauthCredentials = new CredentialSet({
|
|
62
|
+
id: 'oauthApi',
|
|
63
|
+
auth: z.object({
|
|
64
|
+
accessToken: z.string(),
|
|
65
|
+
}),
|
|
66
|
+
stored: refreshTokenSchema,
|
|
67
|
+
resolve: async (stored) => {
|
|
68
|
+
return {
|
|
69
|
+
accessToken: `access-for:${stored.refreshToken}`,
|
|
70
|
+
};
|
|
71
|
+
},
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Use `stored` and `resolve` together. Do not define one without the other.
|
|
76
|
+
|
|
77
|
+
This pattern is useful when the stored values should not be the same values used at runtime.
|
|
78
|
+
|
|
79
|
+
**Type flow.** The third generic on `CredentialSet` (`TStoredSchema`) narrows the
|
|
80
|
+
`stored` argument to `z.output<TStoredSchema>`. Renaming a key on your `stored`
|
|
81
|
+
schema flags the `resolve` hook at compile time, so the stored shape and hook
|
|
82
|
+
body cannot silently drift apart.
|
|
83
|
+
|
|
84
|
+
## Vault mapping (OAuth connections)
|
|
85
|
+
|
|
86
|
+
An OAuth `CredentialSet` tells the platform where to persist each normalized
|
|
87
|
+
token field by declaring a `vault` on its `connection`. Vault keys are typed
|
|
88
|
+
against the set's stored schema (or auth schema when no stored schema is
|
|
89
|
+
provided), so a typo in `vault.accessToken`, `vault.instanceUrl`, or any
|
|
90
|
+
`vault.raw` key is a compile error at the `new CredentialSet({...})` call site
|
|
91
|
+
and a runtime error if the type annotation is erased.
|
|
92
|
+
|
|
93
|
+
### Declarative form
|
|
94
|
+
|
|
95
|
+
Use the declarative form when `tokenResult.accessToken` (optionally
|
|
96
|
+
`tokenResult.instanceUrl`) plus a handful of dotted-path reads off
|
|
97
|
+
`tokenResult.raw` cover every vault slot.
|
|
98
|
+
|
|
99
|
+
```ts
|
|
100
|
+
import { CredentialSet } from '@keystrokehq/core';
|
|
101
|
+
import { createOAuth2Connection } from '@keystroke/credential-connection';
|
|
102
|
+
import { z } from 'zod';
|
|
103
|
+
|
|
104
|
+
export const salesforceCredentials = new CredentialSet({
|
|
105
|
+
id: 'salesforceApi',
|
|
106
|
+
auth: z.object({
|
|
107
|
+
SALESFORCE_ACCESS_TOKEN: z.string(),
|
|
108
|
+
SALESFORCE_INSTANCE_URL: z.string(),
|
|
109
|
+
}),
|
|
110
|
+
connection: createOAuth2Connection({
|
|
111
|
+
authUrl: 'https://login.salesforce.com/services/oauth2/authorize',
|
|
112
|
+
tokenUrl: 'https://login.salesforce.com/services/oauth2/token',
|
|
113
|
+
scopes: ['api', 'refresh_token', 'offline_access'],
|
|
114
|
+
tokenType: 'refreshable',
|
|
115
|
+
vault: {
|
|
116
|
+
accessToken: 'SALESFORCE_ACCESS_TOKEN',
|
|
117
|
+
instanceUrl: 'SALESFORCE_INSTANCE_URL',
|
|
118
|
+
},
|
|
119
|
+
}),
|
|
120
|
+
});
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
Shopify adds a `raw` entry that reads the shop domain out of `tokenResult.raw`
|
|
124
|
+
(the integration stashes it on a private `_shopifyShopDomain` key during
|
|
125
|
+
`exchangeCode`):
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
vault: {
|
|
129
|
+
accessToken: 'SHOPIFY_ACCESS_TOKEN',
|
|
130
|
+
raw: { SHOPIFY_STORE_DOMAIN: '_shopifyShopDomain' },
|
|
131
|
+
},
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
### Function form
|
|
135
|
+
|
|
136
|
+
Use the function form as the escape hatch when the vault layout depends on
|
|
137
|
+
derived values or on post-exchange userinfo stashed on `tokenResult.raw`.
|
|
138
|
+
|
|
139
|
+
```ts
|
|
140
|
+
connection: createOAuth2Connection({
|
|
141
|
+
authUrl: 'https://account.docusign.com/oauth/auth',
|
|
142
|
+
tokenUrl: 'https://account.docusign.com/oauth/token',
|
|
143
|
+
scopes: ['signature', 'extended'],
|
|
144
|
+
tokenType: 'refreshable',
|
|
145
|
+
vault: (tokenResult) => ({
|
|
146
|
+
DOCUSIGN_ACCESS_TOKEN: tokenResult.accessToken,
|
|
147
|
+
DOCUSIGN_ACCOUNT_ID: String(tokenResult.raw._docusignAccountId),
|
|
148
|
+
DOCUSIGN_BASE_URI: String(tokenResult.raw._docusignBaseUri),
|
|
149
|
+
}),
|
|
150
|
+
}),
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
The function receives the full `TokenResult` and returns the full vault write
|
|
154
|
+
map. Every returned key must appear on the stored (or auth) schema.
|
|
155
|
+
|
|
156
|
+
Do not reach for the function form when a declarative mapping works; the
|
|
157
|
+
declarative form stays readable in the manifest and keeps the vault layout
|
|
158
|
+
visible to static analysis. `InstallationInfo` carries only platform identity
|
|
159
|
+
fields (`externalInstallationId`, `externalWorkspaceId`, `externalBotUserId`,
|
|
160
|
+
`metadata`); extra vault entries belong on `vault.raw` (declarative) or in the
|
|
161
|
+
function-form return map.
|
|
162
|
+
|
|
163
|
+
## `validate` hook
|
|
164
|
+
|
|
165
|
+
Runs after Zod parse and before the vault write. Throws to reject; the
|
|
166
|
+
thrown message is user-facing copy. Use it for behavior verification
|
|
167
|
+
("does the provider accept this credential?"), not for shape validation —
|
|
168
|
+
Zod already handled shape.
|
|
169
|
+
|
|
170
|
+
```ts
|
|
171
|
+
// Manual: Stripe secret key check
|
|
172
|
+
connection: {
|
|
173
|
+
kind: 'manual',
|
|
174
|
+
instructions: 'Create a secret key at dashboard.stripe.com/apikeys.',
|
|
175
|
+
validate: async ({ credentials }) => {
|
|
176
|
+
const res = await fetch('https://api.stripe.com/v1/account', {
|
|
177
|
+
headers: { Authorization: `Bearer ${credentials.STRIPE_SECRET_KEY}` },
|
|
178
|
+
});
|
|
179
|
+
if (res.status === 401) {
|
|
180
|
+
throw new Error(
|
|
181
|
+
'Stripe rejected this secret key. Double-check it is copied exactly, ' +
|
|
182
|
+
'is from the right mode (live vs test), and has not been revoked.'
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
if (!res.ok) {
|
|
186
|
+
throw new Error(
|
|
187
|
+
`Stripe returned ${res.status} ${res.statusText} when validating the secret key.`
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
|
|
193
|
+
// OAuth: granted vs requested scope delta
|
|
194
|
+
connection: {
|
|
195
|
+
kind: 'oauth',
|
|
196
|
+
// ...scopes, tokenType, vault...
|
|
197
|
+
validate: async ({ grantedScopes, requestedScopes }) => {
|
|
198
|
+
const missing = requestedScopes.filter((s) => !grantedScopes.includes(s));
|
|
199
|
+
if (missing.length > 0) {
|
|
200
|
+
throw new Error(
|
|
201
|
+
`Install is missing required scopes: ${missing.join(', ')}. ` +
|
|
202
|
+
'Reinstall the app and grant all requested permissions.'
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
},
|
|
206
|
+
},
|
|
207
|
+
```
|
|
208
|
+
|
|
209
|
+
Canonical adopter: `packages/integrations/integration-stripe/src/integration.ts`.
|
|
210
|
+
|
|
211
|
+
## Cached resolve
|
|
212
|
+
|
|
213
|
+
Use the object form of `resolve` to cache the auth value across steps in a
|
|
214
|
+
single workflow run. A 20-step workflow against a credential set with
|
|
215
|
+
`cacheMs: 60_000` invokes `resolve` exactly once; steps 2..N hit the cache.
|
|
216
|
+
|
|
217
|
+
```ts
|
|
218
|
+
import { CredentialSet } from '@keystrokehq/core';
|
|
219
|
+
import { CredentialRevokedError } from '@keystrokehq/core/errors';
|
|
220
|
+
import { z } from 'zod';
|
|
221
|
+
|
|
222
|
+
export const legacyErp = new CredentialSet({
|
|
223
|
+
id: 'legacyErp',
|
|
224
|
+
auth: z.object({ sessionToken: z.string() }),
|
|
225
|
+
stored: z.object({ username: z.string(), password: z.string() }),
|
|
226
|
+
resolve: async ({ username, password }) => {
|
|
227
|
+
const res = await fetch('https://erp.example.com/login', {
|
|
228
|
+
method: 'POST',
|
|
229
|
+
headers: { 'Content-Type': 'application/json' },
|
|
230
|
+
body: JSON.stringify({ username, password }),
|
|
231
|
+
});
|
|
232
|
+
if (!res.ok) throw new CredentialRevokedError('legacyErp', 'Login rejected');
|
|
233
|
+
const { token } = (await res.json()) as { token: string };
|
|
234
|
+
return { sessionToken: token };
|
|
235
|
+
},
|
|
236
|
+
resolveCacheMs: 10 * 60_000, // tokens valid ~15min; refresh at 10min
|
|
237
|
+
onCredentialRevoked: 'retry-once',
|
|
238
|
+
connection: {
|
|
239
|
+
kind: 'manual',
|
|
240
|
+
instructions: 'Enter service-account credentials.',
|
|
241
|
+
},
|
|
242
|
+
});
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
## Revoke-retry
|
|
246
|
+
|
|
247
|
+
```ts
|
|
248
|
+
onCredentialRevoked: 'retry-once',
|
|
249
|
+
connection: {
|
|
250
|
+
kind: 'manual',
|
|
251
|
+
instructions: 'Enter service-account credentials.',
|
|
252
|
+
}
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
Requires `resolve`. On `CredentialRevokedError`, the platform invalidates the
|
|
256
|
+
run's cache for this credential set, re-runs `resolve`, and retries the failing
|
|
257
|
+
step once. Orthogonal to the normal retry budget.
|
|
258
|
+
|
|
259
|
+
## `credentials-exchange` — API-login
|
|
260
|
+
|
|
261
|
+
Provider offers a `POST /login` endpoint that returns a session token +
|
|
262
|
+
refresh token. The user pastes `username` / `password`; the platform
|
|
263
|
+
stores the exchanged session token and rotates via the refresh endpoint.
|
|
264
|
+
|
|
265
|
+
```ts
|
|
266
|
+
import { CredentialSet } from '@keystrokehq/core';
|
|
267
|
+
import { z } from 'zod';
|
|
268
|
+
|
|
269
|
+
export const acmeCrm = new CredentialSet({
|
|
270
|
+
id: 'acmeCrm',
|
|
271
|
+
auth: z.object({ sessionToken: z.string() }),
|
|
272
|
+
stored: z.object({ sessionToken: z.string(), refreshToken: z.string() }),
|
|
273
|
+
resolve: async (stored) => ({ sessionToken: stored.sessionToken }),
|
|
274
|
+
connection: {
|
|
275
|
+
kind: 'credentials-exchange',
|
|
276
|
+
input: z.object({
|
|
277
|
+
username: z.email(),
|
|
278
|
+
password: z.string().min(1),
|
|
279
|
+
}),
|
|
280
|
+
exchange: async ({ input }) => {
|
|
281
|
+
const res = await fetch('https://acme.example.com/auth/login', {
|
|
282
|
+
method: 'POST',
|
|
283
|
+
headers: { 'Content-Type': 'application/json' },
|
|
284
|
+
body: JSON.stringify(input),
|
|
285
|
+
});
|
|
286
|
+
if (res.status === 401) {
|
|
287
|
+
return { status: 'needs-reinput', message: 'Invalid credentials.' };
|
|
288
|
+
}
|
|
289
|
+
if (!res.ok) throw new Error(`Login failed: ${res.status}`);
|
|
290
|
+
const body = await res.json();
|
|
291
|
+
return {
|
|
292
|
+
status: 'exchanged',
|
|
293
|
+
stored: { sessionToken: body.token, refreshToken: body.refresh },
|
|
294
|
+
expiresAt: new Date(Date.now() + body.ttlSec * 1000),
|
|
295
|
+
};
|
|
296
|
+
},
|
|
297
|
+
rotate: async ({ previous }) => {
|
|
298
|
+
const res = await fetch('https://acme.example.com/auth/refresh', {
|
|
299
|
+
method: 'POST',
|
|
300
|
+
body: JSON.stringify({ refresh: previous.refreshToken }),
|
|
301
|
+
});
|
|
302
|
+
if (!res.ok) return { status: 'needs-reinput' };
|
|
303
|
+
const body = await res.json();
|
|
304
|
+
return {
|
|
305
|
+
status: 'exchanged',
|
|
306
|
+
stored: { sessionToken: body.token, refreshToken: body.refresh },
|
|
307
|
+
expiresAt: new Date(Date.now() + body.ttlSec * 1000),
|
|
308
|
+
};
|
|
309
|
+
},
|
|
310
|
+
},
|
|
311
|
+
});
|
|
312
|
+
```
|
|
313
|
+
|
|
314
|
+
## `credentials-exchange` — browser-login
|
|
315
|
+
|
|
316
|
+
Provider has no API; login requires a real browser. Use
|
|
317
|
+
`createBrowserLoginExchange` from `@keystroke/integration-browserbase` to
|
|
318
|
+
drive Browserbase + Stagehand. The LLM never sees plaintext credentials —
|
|
319
|
+
Stagehand substitutes `%fieldName%` at Chromium level.
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
import {
|
|
323
|
+
createBrowserLoginExchange,
|
|
324
|
+
type BrowserbaseCredentials,
|
|
325
|
+
} from '@keystroke/integration-browserbase';
|
|
326
|
+
import { CredentialSet } from '@keystrokehq/core';
|
|
327
|
+
import { z } from 'zod';
|
|
328
|
+
|
|
329
|
+
async function resolveBrowserbaseCredentials(): Promise<BrowserbaseCredentials> {
|
|
330
|
+
// Platform-owned Browserbase credentials, resolved via the internal
|
|
331
|
+
// provider-app credential set for Browserbase.
|
|
332
|
+
return { BROWSERBASE_API_KEY: process.env.BROWSERBASE_API_KEY ?? '' };
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export const legacyPortal = new CredentialSet({
|
|
336
|
+
id: 'legacyPortal',
|
|
337
|
+
auth: z.object({ sessionCookie: z.string() }),
|
|
338
|
+
stored: z.object({
|
|
339
|
+
sessionCookie: z.string(),
|
|
340
|
+
cookieName: z.string(),
|
|
341
|
+
cookieDomain: z.string(),
|
|
342
|
+
browserbaseContextId: z.string(),
|
|
343
|
+
}),
|
|
344
|
+
resolve: async (stored) => ({ sessionCookie: stored.sessionCookie }),
|
|
345
|
+
connection: createBrowserLoginExchange({
|
|
346
|
+
input: z.object({
|
|
347
|
+
username: z.email(),
|
|
348
|
+
password: z.string().min(1),
|
|
349
|
+
}),
|
|
350
|
+
stored: z.object({
|
|
351
|
+
sessionCookie: z.string(),
|
|
352
|
+
cookieName: z.string(),
|
|
353
|
+
cookieDomain: z.string(),
|
|
354
|
+
browserbaseContextId: z.string(),
|
|
355
|
+
}),
|
|
356
|
+
loginUrl: 'https://portal.example.com/login',
|
|
357
|
+
fields: {
|
|
358
|
+
username: 'Type %username% into the email field',
|
|
359
|
+
password: 'Type %password% into the password field',
|
|
360
|
+
},
|
|
361
|
+
submit: 'Click the Sign in button',
|
|
362
|
+
successSignal: { waitForUrl: /\/dashboard/ },
|
|
363
|
+
cookie: { name: 'example_session', domain: 'portal.example.com' },
|
|
364
|
+
resolveBrowserbaseCredentials,
|
|
365
|
+
}),
|
|
366
|
+
});
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
## `credentials-exchange` — service-account JWT
|
|
370
|
+
|
|
371
|
+
User pastes a client email + private-key PEM; the platform mints a fresh
|
|
372
|
+
JWT on exchange and re-mints on rotate. Persisting the PEM in `stored`
|
|
373
|
+
lets rotate run without user input; discarding it forces a reconnect on
|
|
374
|
+
every renewal.
|
|
375
|
+
|
|
376
|
+
```ts
|
|
377
|
+
import { CredentialSet } from '@keystrokehq/core';
|
|
378
|
+
import { SignJWT, importPKCS8 } from 'jose';
|
|
379
|
+
import { z } from 'zod';
|
|
380
|
+
|
|
381
|
+
async function mintJwt(privateKeyPem: string, clientEmail: string): Promise<string> {
|
|
382
|
+
const key = await importPKCS8(privateKeyPem, 'RS256');
|
|
383
|
+
return new SignJWT({ iss: clientEmail })
|
|
384
|
+
.setProtectedHeader({ alg: 'RS256' })
|
|
385
|
+
.setIssuedAt()
|
|
386
|
+
.setExpirationTime('1h')
|
|
387
|
+
.sign(key);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export const googleServiceAccount = new CredentialSet({
|
|
391
|
+
id: 'googleServiceAccount',
|
|
392
|
+
auth: z.object({ jwt: z.string() }),
|
|
393
|
+
stored: z.object({ jwt: z.string(), clientEmail: z.string(), privateKeyPem: z.string() }),
|
|
394
|
+
resolve: async (stored) => ({ jwt: stored.jwt }),
|
|
395
|
+
connection: {
|
|
396
|
+
kind: 'credentials-exchange',
|
|
397
|
+
input: z.object({
|
|
398
|
+
clientEmail: z.email(),
|
|
399
|
+
privateKeyPem: z.string().min(1),
|
|
400
|
+
}),
|
|
401
|
+
exchange: async ({ input }) => {
|
|
402
|
+
const jwt = await mintJwt(input.privateKeyPem, input.clientEmail);
|
|
403
|
+
return {
|
|
404
|
+
status: 'exchanged',
|
|
405
|
+
stored: { jwt, clientEmail: input.clientEmail, privateKeyPem: input.privateKeyPem },
|
|
406
|
+
expiresAt: new Date(Date.now() + 55 * 60_000),
|
|
407
|
+
};
|
|
408
|
+
},
|
|
409
|
+
rotate: async ({ previous }) => {
|
|
410
|
+
const jwt = await mintJwt(previous.privateKeyPem, previous.clientEmail);
|
|
411
|
+
return {
|
|
412
|
+
status: 'exchanged',
|
|
413
|
+
stored: { ...previous, jwt },
|
|
414
|
+
expiresAt: new Date(Date.now() + 55 * 60_000),
|
|
415
|
+
};
|
|
416
|
+
},
|
|
417
|
+
},
|
|
418
|
+
});
|
|
419
|
+
```
|
|
420
|
+
|
|
421
|
+
Design decision: keeping the PEM in `stored` lets rotate run without user
|
|
422
|
+
input. If your threat model requires discarding the PEM after first mint,
|
|
423
|
+
drop it from `stored` and have `rotate` return `'needs-reinput'` instead.
|
|
424
|
+
|
|
425
|
+
## External secrets sources: decision tree
|
|
426
|
+
|
|
427
|
+
**Use `resolve` when:**
|
|
428
|
+
|
|
429
|
+
- The transform is a pure shape / mapping adjustment on vault values.
|
|
430
|
+
- The transform can be performed with no external network call.
|
|
431
|
+
- No platform env vars are needed.
|
|
432
|
+
- Example: HubSpot's auth-modality normalization — `stored` accepts
|
|
433
|
+
either `ACCESS_TOKEN` (OAuth) or `API_KEY` (private app); `resolve`
|
|
434
|
+
picks whichever is populated and returns `{ HUBSPOT_ACCESS_TOKEN }`.
|
|
435
|
+
Throws when neither is present so a missing credential surfaces as
|
|
436
|
+
an actionable resolver error rather than a provider 401. One
|
|
437
|
+
credential set covers both authorization paths; splitting into two
|
|
438
|
+
sets would force every consumer to branch on modality.
|
|
439
|
+
|
|
440
|
+
**Use `resolveAtPlatform` when:**
|
|
441
|
+
|
|
442
|
+
- The transform needs to call a service the sandbox must NOT reach
|
|
443
|
+
(HashiCorp Vault, AWS STS, GCP KMS, 1Password Connect).
|
|
444
|
+
- The transform needs env vars that must NOT be visible to user step
|
|
445
|
+
code (Vault token, KMS IAM credentials).
|
|
446
|
+
- The actual secret lives in an external secrets manager and the
|
|
447
|
+
Keystroke vault holds only a reference.
|
|
448
|
+
- Example: AWS STS `AssumeRole` to mint 15-minute session credentials
|
|
449
|
+
per run.
|
|
450
|
+
|
|
451
|
+
**Use neither when:**
|
|
452
|
+
|
|
453
|
+
- The values in the vault are already what steps need.
|
|
454
|
+
- Most integrations fall here (Slack, GitHub, Stripe).
|
|
455
|
+
|
|
456
|
+
`resolveAtPlatform` is Keystroke-official only in v1 — the credential
|
|
457
|
+
runtime rejects user-namespaced credential sets that declare the hook.
|
|
458
|
+
|
|
459
|
+
### `resolveAtPlatform` — external secrets manager (HashiCorp Vault)
|
|
460
|
+
|
|
461
|
+
```ts
|
|
462
|
+
import { CredentialSet } from '@keystrokehq/core';
|
|
463
|
+
import { z } from 'zod';
|
|
464
|
+
|
|
465
|
+
export const crmApi = new CredentialSet({
|
|
466
|
+
id: 'crmApi',
|
|
467
|
+
namespace: 'keystroke',
|
|
468
|
+
platformMetadata: { kind: 'user-connection', visibility: 'user-visible' },
|
|
469
|
+
auth: z.object({ apiKey: z.string() }),
|
|
470
|
+
stored: z.object({ vaultPath: z.string() }),
|
|
471
|
+
resolveAtPlatform: async (stored, ctx) => {
|
|
472
|
+
const response = await ctx.fetch(`https://vault.internal/v1/${stored.vaultPath}`, {
|
|
473
|
+
headers: { 'X-Vault-Token': ctx.env.VAULT_TOKEN ?? '' },
|
|
474
|
+
});
|
|
475
|
+
if (!response.ok) throw new Error(`Vault rejected path ${stored.vaultPath}`);
|
|
476
|
+
const { data } = (await response.json()) as { data: { apiKey: string } };
|
|
477
|
+
return { apiKey: data.apiKey };
|
|
478
|
+
},
|
|
479
|
+
platformEnvAllowlist: ['VAULT_TOKEN'],
|
|
480
|
+
});
|
|
481
|
+
```
|
|
482
|
+
|
|
483
|
+
`VAULT_TOKEN` is in the executor's environment. `ctx.env.VAULT_TOKEN`
|
|
484
|
+
exposes it to this one hook. User step code sees only
|
|
485
|
+
`ctx.credentials.crmApi.apiKey` — the resolved value.
|
|
486
|
+
|
|
487
|
+
### `resolveAtPlatform` — AWS STS AssumeRole
|
|
488
|
+
|
|
489
|
+
```ts
|
|
490
|
+
import { CredentialSet } from '@keystrokehq/core';
|
|
491
|
+
import { z } from 'zod';
|
|
492
|
+
|
|
493
|
+
declare function assumeRole(params: {
|
|
494
|
+
roleArn: string;
|
|
495
|
+
sessionName: string;
|
|
496
|
+
awsAccessKeyId: string | undefined;
|
|
497
|
+
awsSecretAccessKey: string | undefined;
|
|
498
|
+
}): Promise<{ accessKeyId: string; secretAccessKey: string; sessionToken: string }>;
|
|
499
|
+
|
|
500
|
+
export const awsAssumed = new CredentialSet({
|
|
501
|
+
id: 'awsAssumed',
|
|
502
|
+
namespace: 'keystroke',
|
|
503
|
+
platformMetadata: { kind: 'user-connection', visibility: 'user-visible' },
|
|
504
|
+
auth: z.object({
|
|
505
|
+
accessKeyId: z.string(),
|
|
506
|
+
secretAccessKey: z.string(),
|
|
507
|
+
sessionToken: z.string(),
|
|
508
|
+
}),
|
|
509
|
+
stored: z.object({ roleArn: z.string(), sessionName: z.string() }),
|
|
510
|
+
resolveAtPlatform: async (stored, ctx) => {
|
|
511
|
+
// Calls STS with the platform's IAM credentials (never in the
|
|
512
|
+
// sandbox). The returned session credentials expire in 15 minutes.
|
|
513
|
+
const result = await assumeRole({
|
|
514
|
+
roleArn: stored.roleArn,
|
|
515
|
+
sessionName: stored.sessionName,
|
|
516
|
+
awsAccessKeyId: ctx.env.PLATFORM_AWS_ACCESS_KEY_ID,
|
|
517
|
+
awsSecretAccessKey: ctx.env.PLATFORM_AWS_SECRET_ACCESS_KEY,
|
|
518
|
+
});
|
|
519
|
+
return {
|
|
520
|
+
accessKeyId: result.accessKeyId,
|
|
521
|
+
secretAccessKey: result.secretAccessKey,
|
|
522
|
+
sessionToken: result.sessionToken,
|
|
523
|
+
};
|
|
524
|
+
},
|
|
525
|
+
platformEnvAllowlist: ['PLATFORM_AWS_ACCESS_KEY_ID', 'PLATFORM_AWS_SECRET_ACCESS_KEY'],
|
|
526
|
+
});
|
|
527
|
+
```
|
|
528
|
+
|
|
529
|
+
### `resolveAtPlatform` — KMS-signed JWT
|
|
530
|
+
|
|
531
|
+
```ts
|
|
532
|
+
import { CredentialSet } from '@keystrokehq/core';
|
|
533
|
+
import { z } from 'zod';
|
|
534
|
+
|
|
535
|
+
declare function kmsSignJwt(params: {
|
|
536
|
+
keyArn: string;
|
|
537
|
+
payload: Record<string, unknown>;
|
|
538
|
+
platformAwsAccessKeyId: string | undefined;
|
|
539
|
+
platformAwsSecretAccessKey: string | undefined;
|
|
540
|
+
}): Promise<string>;
|
|
541
|
+
|
|
542
|
+
export const serviceJwt = new CredentialSet({
|
|
543
|
+
id: 'serviceJwt',
|
|
544
|
+
namespace: 'keystroke',
|
|
545
|
+
platformMetadata: { kind: 'user-connection', visibility: 'user-visible' },
|
|
546
|
+
auth: z.object({ jwt: z.string() }),
|
|
547
|
+
stored: z.object({ kmsKeyArn: z.string(), issuer: z.string(), audience: z.string() }),
|
|
548
|
+
resolveAtPlatform: async (stored, ctx) => {
|
|
549
|
+
const jwt = await kmsSignJwt({
|
|
550
|
+
keyArn: stored.kmsKeyArn,
|
|
551
|
+
payload: {
|
|
552
|
+
iss: stored.issuer,
|
|
553
|
+
aud: stored.audience,
|
|
554
|
+
exp: Math.floor(Date.now() / 1000) + 3600,
|
|
555
|
+
},
|
|
556
|
+
platformAwsAccessKeyId: ctx.env.PLATFORM_AWS_ACCESS_KEY_ID,
|
|
557
|
+
platformAwsSecretAccessKey: ctx.env.PLATFORM_AWS_SECRET_ACCESS_KEY,
|
|
558
|
+
});
|
|
559
|
+
return { jwt };
|
|
560
|
+
},
|
|
561
|
+
platformEnvAllowlist: ['PLATFORM_AWS_ACCESS_KEY_ID', 'PLATFORM_AWS_SECRET_ACCESS_KEY'],
|
|
562
|
+
});
|
|
563
|
+
```
|
|
564
|
+
|
|
565
|
+
Signing key never leaves KMS. The sandbox sees only the freshly-signed
|
|
566
|
+
JWT.
|
|
567
|
+
|
|
568
|
+
## Customizing OAuth flows with presets
|
|
569
|
+
|
|
570
|
+
When your OAuth provider deviates from generic OAuth 2.0, use `pipe` plus the
|
|
571
|
+
`oauthPresets` catalog from `@keystroke/credential-connection` instead of hand-rolling
|
|
572
|
+
the whole flow. Reach for presets for scope delimiters, authorize-URL
|
|
573
|
+
parameters, Basic-auth + JSON-body token exchange, success-flag checks, and
|
|
574
|
+
post-exchange identity fetches.
|
|
575
|
+
|
|
576
|
+
```ts
|
|
577
|
+
import {
|
|
578
|
+
buildDefaultAuthUrl,
|
|
579
|
+
exchangeCodeDefault,
|
|
580
|
+
oauthPresets as p,
|
|
581
|
+
pipe,
|
|
582
|
+
} from '@keystroke/credential-connection';
|
|
583
|
+
|
|
584
|
+
connection: {
|
|
585
|
+
kind: 'oauth',
|
|
586
|
+
// ...urls, scopes, tokenType, vault...
|
|
587
|
+
buildAuthUrl: pipe(buildDefaultAuthUrl, p.scopeDelimiter(',')),
|
|
588
|
+
exchangeCode: pipe(exchangeCodeDefault, p.requireSuccessFlag('ok')),
|
|
589
|
+
},
|
|
590
|
+
```
|
|
591
|
+
|
|
592
|
+
See the "Customizing OAuth" section in `packages/credential-connection/README.md` for
|
|
593
|
+
the preset catalog and worked examples from Slack, Linear, and Notion.
|
|
594
|
+
|
|
595
|
+
## Step usage
|
|
596
|
+
|
|
597
|
+
```ts
|
|
598
|
+
import { Step } from '@keystrokehq/core';
|
|
599
|
+
import { z } from 'zod';
|
|
600
|
+
|
|
601
|
+
export const loadCustomer = new Step({
|
|
602
|
+
name: 'Load Customer',
|
|
603
|
+
description: 'Reads a CRM credential from step context.',
|
|
604
|
+
credentialSets: [crmCredentials],
|
|
605
|
+
input: z.object({
|
|
606
|
+
customerId: z.string(),
|
|
607
|
+
}),
|
|
608
|
+
output: z.object({
|
|
609
|
+
customerId: z.string(),
|
|
610
|
+
keyUsed: z.string(),
|
|
611
|
+
}),
|
|
612
|
+
run: async (input, ctx) => {
|
|
613
|
+
return {
|
|
614
|
+
customerId: input.customerId,
|
|
615
|
+
keyUsed: ctx.credentials.crmApi.apiKey,
|
|
616
|
+
};
|
|
617
|
+
},
|
|
618
|
+
});
|
|
619
|
+
```
|
|
620
|
+
|
|
621
|
+
The same workflow example could also be written with `new Operation({...})` if the credential-backed unit is shared outside workflow code.
|
|
622
|
+
|
|
623
|
+
## Tool usage
|
|
624
|
+
|
|
625
|
+
```ts
|
|
626
|
+
import { Tool } from '@keystrokehq/core';
|
|
627
|
+
import { z } from 'zod';
|
|
628
|
+
|
|
629
|
+
export const lookupTicketTool = new Tool({
|
|
630
|
+
name: 'Lookup Ticket',
|
|
631
|
+
description: 'Reads credentials from tool context.',
|
|
632
|
+
credentialSets: [crmCredentials],
|
|
633
|
+
input: z.object({
|
|
634
|
+
ticketId: z.string(),
|
|
635
|
+
}),
|
|
636
|
+
output: z.object({
|
|
637
|
+
ticketId: z.string(),
|
|
638
|
+
keyUsed: z.string(),
|
|
639
|
+
}),
|
|
640
|
+
run: async (input, ctx) => {
|
|
641
|
+
return {
|
|
642
|
+
ticketId: input.ticketId,
|
|
643
|
+
keyUsed: ctx.credentials.crmApi.apiKey,
|
|
644
|
+
};
|
|
645
|
+
},
|
|
646
|
+
});
|
|
647
|
+
```
|
|
648
|
+
|
|
649
|
+
The same agent example could also be written with `new Operation({...})` if the credential-backed unit is shared outside agent code.
|
|
650
|
+
|
|
651
|
+
## Agent usage
|
|
652
|
+
|
|
653
|
+
```ts
|
|
654
|
+
import { anthropic } from '@keystroke/integration-ai';
|
|
655
|
+
import { Agent } from '@keystrokehq/core';
|
|
656
|
+
|
|
657
|
+
export const supportAgent = new Agent({
|
|
658
|
+
id: 'support-agent',
|
|
659
|
+
name: 'Support Agent',
|
|
660
|
+
systemPrompt: 'Use the CRM credential-backed tools before answering.',
|
|
661
|
+
model: 'anthropic/claude-sonnet-4-20250514',
|
|
662
|
+
credentialSets: [anthropic, crmCredentials],
|
|
663
|
+
tools: [lookupTicketTool],
|
|
664
|
+
});
|
|
665
|
+
```
|
|
666
|
+
|
|
667
|
+
This attaches credentials directly to the agent and also allows tools on the agent to have their own additional credential sets.
|
|
668
|
+
|
|
669
|
+
## Trigger usage
|
|
670
|
+
|
|
671
|
+
```ts
|
|
672
|
+
import { WebhookTrigger } from '@keystrokehq/core';
|
|
673
|
+
import { z } from 'zod';
|
|
674
|
+
|
|
675
|
+
export const signedWebhook = new WebhookTrigger({
|
|
676
|
+
name: 'Signed Webhook',
|
|
677
|
+
description: 'Verifies a webhook with a credential-backed secret.',
|
|
678
|
+
path: '/signed',
|
|
679
|
+
method: 'POST',
|
|
680
|
+
credentialSets: [crmCredentials],
|
|
681
|
+
payload: z.object({
|
|
682
|
+
id: z.string(),
|
|
683
|
+
}),
|
|
684
|
+
verify: async (_request, ctx) => {
|
|
685
|
+
const secret = ctx.credentials.crmApi.apiKey;
|
|
686
|
+
if (!secret) {
|
|
687
|
+
throw new Error('Missing signing secret');
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
});
|
|
691
|
+
```
|
|
692
|
+
|
|
693
|
+
This pattern is useful when the trigger itself must verify or authenticate incoming events.
|
|
694
|
+
|
|
695
|
+
## MCP server usage
|
|
696
|
+
|
|
697
|
+
```ts
|
|
698
|
+
import { McpServer } from '@keystrokehq/core';
|
|
699
|
+
|
|
700
|
+
export const crmMcpServer = new McpServer({
|
|
701
|
+
id: 'crm-mcp',
|
|
702
|
+
transport: {
|
|
703
|
+
type: 'http',
|
|
704
|
+
url: 'https://mcp.example.com',
|
|
705
|
+
},
|
|
706
|
+
credentialSets: [crmCredentials],
|
|
707
|
+
credentialMapper: (credentials) => ({
|
|
708
|
+
headers: {
|
|
709
|
+
Authorization: `Bearer ${credentials.crmApi.apiKey}`,
|
|
710
|
+
},
|
|
711
|
+
}),
|
|
712
|
+
});
|
|
713
|
+
```
|
|
714
|
+
|
|
715
|
+
Use `credentialMapper(...)` to convert Keystroke credential values into transport-specific config such as headers, env vars, or query parameters.
|
|
716
|
+
|
|
717
|
+
## `needsResolve`
|
|
718
|
+
|
|
719
|
+
```ts
|
|
720
|
+
const shouldResolve = oauthCredentials.needsResolve;
|
|
721
|
+
```
|
|
722
|
+
|
|
723
|
+
`needsResolve` is `true` only when the credential set was defined with both `stored` and `resolve`.
|
|
724
|
+
|
|
725
|
+
## `describe()` and `toManifest()`
|
|
726
|
+
|
|
727
|
+
```ts
|
|
728
|
+
const summary = crmCredentials.describe();
|
|
729
|
+
const manifest = crmCredentials.toManifest();
|
|
730
|
+
```
|
|
731
|
+
|
|
732
|
+
Use `describe()` for a short human-readable summary and `toManifest()` when the caller needs the manifest form.
|
|
733
|
+
|
|
734
|
+
## Boundary reminder
|
|
735
|
+
|
|
736
|
+
- `workflowGlobals` are not credentials
|
|
737
|
+
- env vars are an input source for upload, not the primary primitive-level credential API
|
|
738
|
+
- prefer `CredentialSet` plus typed context access inside Keystroke primitives
|
|
739
|
+
- prefer `Step` or `Tool` naming only to communicate context; the underlying credential-backed primitive is still `Operation`
|
|
740
|
+
|
|
741
|
+
## Type vs instance: one `CredentialSet`, many connected accounts
|
|
742
|
+
|
|
743
|
+
One `CredentialSet` declared in code is a **type**. The platform can store many connected **instances** of that type in the vault, one per user, workspace, or explicit binding.
|
|
744
|
+
|
|
745
|
+
```text
|
|
746
|
+
┌───────────────────────────────────────────────────────────────────┐
|
|
747
|
+
│ Credential Set TYPE │
|
|
748
|
+
│ Declared in code. One per integration. │
|
|
749
|
+
│ Example: keystroke:gmail │
|
|
750
|
+
│ schema = { access_token: string, refresh_token: string } │
|
|
751
|
+
│ connection = OAuth 2.0 with Google │
|
|
752
|
+
└───────────────────────────────────────────────────────────────────┘
|
|
753
|
+
│
|
|
754
|
+
│ (one-to-many)
|
|
755
|
+
▼
|
|
756
|
+
┌──────────────────────┐ ┌──────────────────────┐
|
|
757
|
+
│ Credential Set │ │ Credential Set │
|
|
758
|
+
│ INSTANCE │ │ INSTANCE │
|
|
759
|
+
│ DB row │ │ DB row │
|
|
760
|
+
│ id: cset_personal │ │ id: cset_work │
|
|
761
|
+
│ name: "Personal" │ │ name: "Work" │
|
|
762
|
+
│ externalInst: me@.. │ │ externalInst: nate.. │
|
|
763
|
+
│ tokens: ... │ │ tokens: ... │
|
|
764
|
+
└──────────────────────┘ └──────────────────────┘
|
|
765
|
+
```
|
|
766
|
+
|
|
767
|
+
What authors often try first is the anti-pattern:
|
|
768
|
+
|
|
769
|
+
```ts
|
|
770
|
+
import { gmail } from '@keystroke/integration-google';
|
|
771
|
+
import { Step } from '@keystrokehq/core';
|
|
772
|
+
|
|
773
|
+
export const brokenStep = new Step({
|
|
774
|
+
name: 'Broken',
|
|
775
|
+
credentialSets: [gmail, gmail],
|
|
776
|
+
run: async () => undefined,
|
|
777
|
+
});
|
|
778
|
+
```
|
|
779
|
+
|
|
780
|
+
That does not model two Gmail accounts. It declares the same credential type twice and collides on the same raw `id`.
|
|
781
|
+
|
|
782
|
+
The supported path is **multi-step decomposition**: one credential set type, multiple steps, and explicit runtime bindings that select a different stored instance per step node.
|
|
783
|
+
|
|
784
|
+
```ts
|
|
785
|
+
// One credential set TYPE, declared exactly once (by the integration).
|
|
786
|
+
import { gmail } from '@keystroke/integration-google';
|
|
787
|
+
import { Step, Workflow } from '@keystrokehq/core';
|
|
788
|
+
import { z } from 'zod';
|
|
789
|
+
|
|
790
|
+
async function fetchInbox(_credentials: unknown) {
|
|
791
|
+
return [];
|
|
792
|
+
}
|
|
793
|
+
|
|
794
|
+
const readPersonal = new Step({
|
|
795
|
+
name: 'Read Personal Inbox',
|
|
796
|
+
credentialSets: [gmail],
|
|
797
|
+
input: z.object({}),
|
|
798
|
+
output: z.object({ messages: z.array(z.unknown()) }),
|
|
799
|
+
run: async (_, ctx) => ({ messages: await fetchInbox(ctx.credentials.gmail) }),
|
|
800
|
+
});
|
|
801
|
+
|
|
802
|
+
const readWork = new Step({
|
|
803
|
+
name: 'Read Work Inbox',
|
|
804
|
+
credentialSets: [gmail],
|
|
805
|
+
input: z.object({}),
|
|
806
|
+
output: z.object({ messages: z.array(z.unknown()) }),
|
|
807
|
+
run: async (_, ctx) => ({ messages: await fetchInbox(ctx.credentials.gmail) }),
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
const aggregate = new Step({
|
|
811
|
+
name: 'Aggregate Inbox Digest',
|
|
812
|
+
input: z.object({
|
|
813
|
+
personal: z.object({ messages: z.array(z.unknown()) }),
|
|
814
|
+
work: z.object({ messages: z.array(z.unknown()) }),
|
|
815
|
+
}),
|
|
816
|
+
output: z.object({
|
|
817
|
+
totalMessages: z.number(),
|
|
818
|
+
}),
|
|
819
|
+
run: async (input) => ({
|
|
820
|
+
totalMessages: input.personal.messages.length + input.work.messages.length,
|
|
821
|
+
}),
|
|
822
|
+
});
|
|
823
|
+
|
|
824
|
+
export const inboxDigest = new Workflow({
|
|
825
|
+
id: 'inbox-digest',
|
|
826
|
+
input: z.object({}),
|
|
827
|
+
output: z.object({ totalMessages: z.number() }),
|
|
828
|
+
run: async () => {
|
|
829
|
+
const personal = await readPersonal.run({});
|
|
830
|
+
const work = await readWork.run({});
|
|
831
|
+
return aggregate.run({ personal, work });
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
```
|
|
835
|
+
|
|
836
|
+
At run time, the operator connects both Gmail accounts, which creates two vault rows under `resolvedCredentialSetId: 'keystroke:gmail'`, then chooses which one backs each call site:
|
|
837
|
+
|
|
838
|
+
```jsonc
|
|
839
|
+
{
|
|
840
|
+
"workflowId": "inbox-digest",
|
|
841
|
+
"credentialBindings": {
|
|
842
|
+
"readPersonal:keystroke:gmail": "cset_personal",
|
|
843
|
+
"readWork:keystroke:gmail": "cset_work"
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
```
|
|
847
|
+
|
|
848
|
+
Each step node resolves its credential slot independently. `ctx.credentials.gmail` inside `readPersonal.run(...)` points at the personal account, while `ctx.credentials.gmail` inside `readWork.run(...)` points at the work account. No alias, no extra primitive, no collision.
|
|
849
|
+
|
|
850
|
+
See `packages/test-workflows/src/e2e/07-reuse-step.e2e.test.ts` for an end-to-end test that exercises this mechanism with Slack.
|
|
851
|
+
|
|
852
|
+
## The drawer-label rule
|
|
853
|
+
|
|
854
|
+
One drawer per `id`, ever. A `CredentialSet` declaration is the label on the drawer, not an individual connection row. Two declarations with the same `id` do not create two drawers.
|
|
855
|
+
|
|
856
|
+
When a collision is accidental, the platform is increasingly strict about catching it early:
|
|
857
|
+
|
|
858
|
+
- compile-time on a single primitive via `AssertUniqueCredentialSetIds`
|
|
859
|
+
- build-time across the project via `assertUniqueCredentialSetResolvedIds`
|
|
860
|
+
- deploy-time via schema-drift detection
|
|
861
|
+
- later in the reconciled credential roadmap, construction-time identity registry and vault-row schema fingerprinting close the remaining gaps
|
|
862
|
+
|
|
863
|
+
When "same id" is actually a multi-instance requirement, the answer is still **one declaration, many connected rows**.
|
|
864
|
+
|
|
865
|
+
```ts
|
|
866
|
+
import { stripe } from '@keystroke/integration-stripe';
|
|
867
|
+
|
|
868
|
+
// One declaration in code.
|
|
869
|
+
export const billingCredential = stripe;
|
|
870
|
+
|
|
871
|
+
// Two connected rows in storage:
|
|
872
|
+
// - cset_stripe_test
|
|
873
|
+
// - cset_stripe_live
|
|
874
|
+
//
|
|
875
|
+
// Bind the right one per step node at run time with credentialBindings.
|
|
876
|
+
```
|
|
877
|
+
|
|
878
|
+
Do **not** write two `CredentialSet`s just because you need two Stripe accounts. Write one credential set type, connect it twice, and bind the specific row you want for each step.
|