@amalgm/automations 0.3.2 → 0.4.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.
Files changed (42) hide show
  1. package/AXIOMS.md +16 -1
  2. package/PURPOSE.md +30 -0
  3. package/README.md +53 -4
  4. package/dist/host/auth.js +4 -1
  5. package/dist/host/main.js +11 -1
  6. package/dist/host/notifications.d.ts +4 -0
  7. package/dist/host/notifications.js +19 -0
  8. package/dist/host/server.d.ts +1 -0
  9. package/dist/host/server.js +51 -10
  10. package/dist/host/skill.d.ts +2 -0
  11. package/dist/host/skill.js +33 -0
  12. package/dist/skills/amalgm-automations.5e4e14f0ace632c8383cd014e1f0f34b24ec0d5c5600132a840abc4c313b31d4.tgz +0 -0
  13. package/dist/skills/index.json +1 -0
  14. package/dist/src/cli/run.d.ts +1 -1
  15. package/dist/src/cli/run.js +17 -0
  16. package/dist/src/crud/triggers.js +0 -1
  17. package/dist/src/index.d.ts +1 -0
  18. package/dist/src/index.js +1 -0
  19. package/dist/src/machine-client.d.ts +3 -1
  20. package/dist/src/machine-client.js +6 -0
  21. package/dist/src/machine-event-stream.d.ts +8 -0
  22. package/dist/src/machine-event-stream.js +58 -0
  23. package/dist/src/machine-http.d.ts +2 -0
  24. package/dist/src/machine-http.js +8 -2
  25. package/dist/src/machine-notifications.d.ts +15 -0
  26. package/dist/src/machine-notifications.js +124 -0
  27. package/dist/src/machine.d.ts +1 -0
  28. package/dist/src/mcp.d.ts +2 -1
  29. package/dist/src/mcp.js +4 -2
  30. package/dist/src/runner.d.ts +3 -1
  31. package/dist/src/runner.js +69 -46
  32. package/dist/src/schema.js +9 -3
  33. package/dist/src/supabase-machine.d.ts +1 -0
  34. package/dist/src/supabase-machine.js +3 -0
  35. package/dist/src/tool-surface.js +21 -2
  36. package/package.json +3 -2
  37. package/skills/amalgm-automations/SKILL.md +49 -11
  38. package/skills/amalgm-automations/agents/openai.yaml +2 -2
  39. package/skills/amalgm-automations/references/command-contract.md +7 -0
  40. package/skills/amalgm-automations/references/setup-and-support.md +236 -0
  41. package/supabase/migrations/20260904020000_machine_run_notifications.sql +52 -0
  42. package/supabase/migrations/20260904030000_machine_notification_permissions.sql +4 -0
package/AXIOMS.md CHANGED
@@ -91,7 +91,8 @@
91
91
  33. MCP and CLI project one task-level command catalog: create, list, get,
92
92
  update, delete, and run-now. Command names, input schemas, and invocation
93
93
  behavior are defined once and neither adapter invents resource-level
94
- lifecycle operations.
94
+ lifecycle operations. Unknown configuration fields are rejected, never
95
+ discarded by an adapter; declared JSON payloads remain open data.
95
96
  34. A CLI running for a signed-in user reaches Automations through Shell's
96
97
  authenticated loopback MCP route. It may possess the local runtime admission
97
98
  token, but never a durable machine credential, device key, cached access
@@ -129,3 +130,17 @@
129
130
  action or process results become an explicit byte count plus bounded JSON
130
131
  preview before transport, so output can never strand a run outside the
131
132
  ledger it is meant to update.
133
+ 44. Agent onboarding delegates identity and runtime lifecycle to Shell and
134
+ proves Automations access with a successful read before declaring the
135
+ connection ready.
136
+ 45. A machine claims work only after notification coverage is ready, on a wakeup,
137
+ or while draining previously discovered work; idle machines never poll.
138
+ 46. Every run is committed before its wakeup, and every Fly host observes the
139
+ same private database notifications regardless of where admission occurred.
140
+ 47. Retry and expired-lease wakeups follow the earliest durable eligibility time
141
+ for that user and machine; a notification is never proof of execution.
142
+ 48. Losing upstream notification coverage closes downstream streams, and every
143
+ reconnect checks retained work before becoming idle again.
144
+ 49. A complete definition validates all supplied configuration before its
145
+ first write; a later service failure preserves an identifiable disabled
146
+ draft and the original failure code.
package/PURPOSE.md CHANGED
@@ -27,6 +27,21 @@ The product has two composable halves over that one state:
27
27
  needs to know whether a machine is online: an unclaimed run is the complete
28
28
  offline queue.
29
29
 
30
+ The local worker is idle when there is no work. It opens one authenticated
31
+ HTTPS notification stream, claims retained runs when that stream becomes ready,
32
+ and drains work when notified. Committed Supabase changes wake every Fly host;
33
+ each host forwards only to the owning machine and schedules the next durable
34
+ retry or lease deadline. Notifications carry no workflow data and never replace
35
+ the run ledger. Reconnecting re-establishes notification coverage before checking
36
+ the ledger. Connection health and active execution leases have bounded timers;
37
+ idle machines have no periodic work-claim timer.
38
+
39
+ Complete create and update requests validate their supplied schedules, workflow
40
+ plans, and field names before writing configuration. Unknown fields cannot
41
+ silently become a different request. If a service fails after staging begins,
42
+ the disabled draft remains inspectable and the error keeps that service's code,
43
+ so the agent can repair the same definition rather than repeat its creation.
44
+
30
45
  The agent CLI is the command-line projection of the same task-level command
31
46
  surface as MCP: create, list, get, update, delete, and run-now. Each command
32
47
  accepts the same JSON object as its corresponding MCP tool and returns the same
@@ -38,6 +53,21 @@ CLI sees only the local runtime admission token. The standalone adapter may be
38
53
  composed over an already-bound SDK for tests and other hosts, but it never owns
39
54
  automation lifecycle or authorization rules.
40
55
 
56
+ The portable Automations skill guides an agent from installation and Google
57
+ sign-in to a verified Automations connection, then back to the user's requested
58
+ work. Setup uses Shell's public commands and the existing browser approval;
59
+ the skill never becomes an authentication implementation. It reuses a working
60
+ connection, distinguishes account approval from runtime readiness, and helps
61
+ the user reach aayush@amalgm.ai with bounded, redacted diagnostic evidence when
62
+ the failing boundary cannot be repaired. The packaged skill is the source for
63
+ installed copies and public setup/support guidance.
64
+
65
+ The standalone service publishes a public skill discovery index and an
66
+ integrity-checked archive built from that same packaged skill. Installation
67
+ needs no private repository access, and the archive includes its referenced
68
+ instructions. Public distribution owns no copy of authentication or workflow
69
+ behavior.
70
+
41
71
  The CLI is global configuration control, not directory-local state. The agent
42
72
  or person creating an automation may invoke it from any directory; the selected
43
73
  machine and the persisted workflow decide where effects occur later. A process
package/README.md CHANGED
@@ -47,7 +47,10 @@ the API request.
47
47
  - `@amalgm/automations/host`: standalone Fly service composition.
48
48
  - `amalgm-automations`: CLI adapter.
49
49
  - `skills/amalgm-automations`: portable agent skill for operating either the
50
- MCP or global CLI surface safely.
50
+ MCP or global CLI surface, including installation, Google sign-in, runtime
51
+ readiness, troubleshooting, and support. The packaged skill is the source
52
+ for installed copies; its [setup and support reference](skills/amalgm-automations/references/setup-and-support.md)
53
+ supplies the public onboarding guidance.
51
54
 
52
55
  ## Agent CLI
53
56
 
@@ -95,15 +98,32 @@ one JSON line on stderr shaped as
95
98
  `status`, and exits nonzero. Use `--stdin` for inputs containing credentials;
96
99
  `--input` and `--file` are also supported.
97
100
 
98
- When Shell invokes the adapter, it supplies `AMALGM_MCP_URL` and
99
- `AMALGM_RUNTIME_TOKEN` from its running user runtime. The CLI sends that
100
- runtime token only to the loopback `/mcp/automations` route. Shell retains the
101
+ Shell supplies the selected running user's loopback connection directly to
102
+ the SDK adapter. The CLI sends its runtime token only to the loopback
103
+ `/mcp/automations` route. Shell retains the
101
104
  durable machine credential and device key and creates the short-lived access
102
105
  token and fresh DPoP proof for the hosted request. For transition
103
106
  compatibility, the standalone entry point still accepts the prior paired
104
107
  `AMALGM_AUTOMATIONS_API_URL` and `AMALGM_AUTOMATIONS_AUTHORIZATION`
105
108
  environment variables.
106
109
 
110
+ For public local MCP clients, Shell 0.1.176+ provides
111
+ `amalgm automations mcp --user person@example.com`. It exposes the same six
112
+ tools over stdio and discovers the current runtime connection per call.
113
+ `amalgm status --all` lists local account registrations. No pasted token or
114
+ private repository access is part of either flow; the standalone
115
+ `amalgm-automations-mcp` entry point remains for custom authenticated hosts.
116
+
117
+ Install the complete portable skill with Node.js 22.20+:
118
+
119
+ ```bash
120
+ npx skills add https://automations.amalgm.ai --skill amalgm-automations
121
+ ```
122
+
123
+ The host serves standard discovery at `/.well-known/agent-skills/index.json`
124
+ and a checksum-addressed archive built from `skills/amalgm-automations`.
125
+ The archive includes its references and agent metadata and requires no login.
126
+
107
127
  The hosted service accepts verified Supabase user sessions for browser control
108
128
  and Core-issued `amalgm-automations` DPoP grants for Shell. It does not run in
109
129
  or depend on Amalgm Gateway.
@@ -200,6 +220,7 @@ The machine execution API is:
200
220
  ```text
201
221
  POST /v1/machine/runs/claim
202
222
  PATCH /v1/machine/runs/:runId
223
+ GET /v1/machine/runs/notifications
203
224
  ```
204
225
 
205
226
  Public webhook admission uses the URL returned on each webhook trigger:
@@ -218,13 +239,41 @@ The target is never accepted in a machine request. It comes from the verified
218
239
  access token. Run leases expire and are safely reclaimable; terminal updates
219
240
  must present the active lease token.
220
241
 
242
+ The machine worker opens the authenticated HTTPS notification stream before
243
+ its first claim. `ready` and `wake` events cause it to drain retained work;
244
+ an idle worker has no polling interval. The stream sends a comment every
245
+ 30 seconds to detect broken connections; comments never cause a claim or a
246
+ database read. Streams reconnect with fresh authorization at token expiry
247
+ (at most five minutes), and reconnect checks recover missed notifications.
248
+
249
+ Each Fly host subscribes to the same service-only Supabase Broadcast topic.
250
+ Run inserts and eligibility changes publish small owner/target wakeups from
251
+ the database transaction. Fly re-reads the earliest retry/lease deadline only
252
+ when a target connects, changes, or reaches that deadline. Losing the database
253
+ subscription closes the machine streams so they reconnect and catch up.
254
+ Cron scheduling remains hosted; no schedule timer runs on the user's machine.
255
+
256
+ Rollout order: apply the product migrations, deploy the Fly host, publish the
257
+ SDK, then release Shell with that exact SDK version. Shell wires
258
+ `notifications: runs.notifications` into `createAutomationMachineRunner`.
259
+ The polling option and `AMALGM_AUTOMATIONS_POLL_INTERVAL_MS` are removed.
260
+ Existing installed Shell releases keep their old behavior until updated.
261
+
221
262
  ## Verification
222
263
 
223
264
  ```bash
224
265
  npm run verify
225
266
  TEST_DATABASE_URL=postgres://... npm run test:supabase
267
+ npm run test:notifications
226
268
  npm pack --dry-run
227
269
  ```
228
270
 
271
+ `test:supabase` requires an empty disposable Postgres database; its fixture
272
+ records the Supabase Broadcast boundary inside the same transaction.
273
+ `test:notifications` requires Node.js 22+, Docker, and Supabase CLI 2.26.9
274
+ (also pinned in CI). It starts and removes its own local Supabase project,
275
+ using real database Broadcast and two HTTP hosts to verify delivery, idle
276
+ behavior, offline catch-up, retry deadlines, and abandoned claims.
277
+
229
278
  See [PURPOSE.md](./PURPOSE.md) and [AXIOMS.md](./AXIOMS.md) for the governing
230
279
  ownership and behavior laws.
package/dist/host/auth.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import { authorizeMachineResource, } from '@amalgm/core/authorization';
3
- import { calculateJwkThumbprint, createRemoteJWKSet, decodeProtectedHeader, importJWK, jwtVerify, } from 'jose';
3
+ import { calculateJwkThumbprint, createRemoteJWKSet, decodeProtectedHeader, decodeJwt, importJWK, jwtVerify, } from 'jose';
4
4
  import { ForbiddenError } from '../src/errors.js';
5
5
  export function createAutomationsAuthenticators(options) {
6
6
  const jwks = createRemoteJWKSet(new URL(`${options.issuer}/.well-known/jwks.json`));
@@ -28,10 +28,13 @@ export function createAutomationsAuthenticators(options) {
28
28
  },
29
29
  machine: async (request) => {
30
30
  const principal = await authorize(request, options.issuer, 'runs:execute', ports);
31
+ // The exact token above has already passed Core's signature/lifetime checks.
32
+ const claims = decodeJwt(request.headers.get('authorization').trim().split(/\s+/)[1]);
31
33
  return {
32
34
  userId: principal.userId,
33
35
  computerId: principal.computerId,
34
36
  scopes: automationScopes(principal.scopes),
37
+ authorizationExpiresAt: claims.exp * 1000,
35
38
  };
36
39
  },
37
40
  });
package/dist/host/main.js CHANGED
@@ -12,6 +12,9 @@ import { WebhookEndpoints } from '../src/webhook.js';
12
12
  import { createAutomationsAuthenticators } from './auth.js';
13
13
  import { automationsHostConfig } from './config.js';
14
14
  import { createAutomationsHost } from './server.js';
15
+ import { createMachineRunNotifications } from '../src/machine-notifications.js';
16
+ import { subscribeToRunChanges } from './notifications.js';
17
+ import { createPublicSkillApi } from './skill.js';
15
18
  const config = automationsHostConfig();
16
19
  const supabase = createClient(config.supabaseUrl, config.supabaseServiceRoleKey, {
17
20
  auth: { persistSession: false, autoRefreshToken: false },
@@ -27,9 +30,15 @@ const controlApi = createAutomationApi({
27
30
  authenticate: authentication.control,
28
31
  });
29
32
  const machineRepository = new SupabaseMachineRunRepository(supabase);
33
+ const notifications = createMachineRunNotifications({
34
+ wakeDelay: (userId, computerId) => machineRepository.wakeDelay(userId, computerId),
35
+ log,
36
+ });
37
+ const closeNotifications = subscribeToRunChanges(supabase, notifications);
30
38
  const machineApi = createMachineRunsApi({
31
39
  authenticate: authentication.machine,
32
40
  runsFor: (principal) => createMachineRuns(machineRepository, principal),
41
+ notifications,
33
42
  });
34
43
  const eventsApi = createAutomationEventsApi({
35
44
  delivery,
@@ -40,13 +49,14 @@ const host = createAutomationsHost({
40
49
  controlApi,
41
50
  machineApi,
42
51
  eventsApi,
52
+ publicSkillApi: createPublicSkillApi(),
43
53
  fireSchedules: () => delivery.fireDueCrons(),
44
54
  schedulerIntervalMs: config.schedulerIntervalMs,
45
55
  log,
46
56
  });
47
57
  host.server.listen(config.port, '0.0.0.0', () => log('host.ready', { port: config.port }));
48
58
  for (const signal of ['SIGINT', 'SIGTERM']) {
49
- process.once(signal, () => void host.close().finally(() => process.exit(0)));
59
+ process.once(signal, () => void closeNotifications().then(() => host.close()).finally(() => process.exit(0)));
50
60
  }
51
61
  function log(event, details = {}) {
52
62
  console.log(JSON.stringify({ service: 'amalgm-automations', event, ...details }));
@@ -0,0 +1,4 @@
1
+ import type { SupabaseClient } from '@supabase/supabase-js';
2
+ import type { MachineRunNotifications } from '../src/machine-notifications.js';
3
+ /** One private database subscription per Fly host, shared by its machine streams. */
4
+ export declare function subscribeToRunChanges(supabase: SupabaseClient, notifications: MachineRunNotifications): () => Promise<void>;
@@ -0,0 +1,19 @@
1
+ /** One private database subscription per Fly host, shared by its machine streams. */
2
+ export function subscribeToRunChanges(supabase, notifications) {
3
+ const channel = supabase.channel('amalgm:automations:runs', { config: { private: true } })
4
+ .on('broadcast', { event: 'changed' }, ({ payload }) => {
5
+ if (typeof payload?.userId === 'string' && typeof payload?.targetId === 'string') {
6
+ notifications.changed(payload.userId, payload.targetId);
7
+ }
8
+ })
9
+ .subscribe((status) => {
10
+ if (status === 'SUBSCRIBED')
11
+ notifications.connected();
12
+ else
13
+ notifications.disconnected();
14
+ });
15
+ return async () => {
16
+ notifications.close();
17
+ await supabase.removeChannel(channel);
18
+ };
19
+ }
@@ -4,6 +4,7 @@ export declare function createAutomationsHost(options: {
4
4
  readonly controlApi: (request: Request) => Promise<Response>;
5
5
  readonly machineApi: (request: Request) => Promise<Response>;
6
6
  readonly eventsApi?: (request: Request) => Promise<Response>;
7
+ readonly publicSkillApi?: (request: Request) => Promise<Response>;
7
8
  readonly fireSchedules: () => Promise<unknown>;
8
9
  readonly schedulerIntervalMs: number;
9
10
  readonly maxRequestBodyBytes?: number;
@@ -1,7 +1,9 @@
1
1
  import { createServer } from 'node:http';
2
+ import { once } from 'node:events';
2
3
  import { publicResourceRequestUrl } from '@amalgm/core/authorization';
3
4
  export function createAutomationsHost(options) {
4
5
  const log = options.log ?? (() => { });
6
+ const requests = new Set();
5
7
  let scheduling = null;
6
8
  const tick = () => {
7
9
  if (scheduling)
@@ -14,34 +16,50 @@ export function createAutomationsHost(options) {
14
16
  timer.unref();
15
17
  tick();
16
18
  const server = createServer(async (incoming, outgoing) => {
19
+ const controller = new AbortController();
20
+ requests.add(controller);
21
+ const abort = () => controller.abort();
22
+ outgoing.once('close', abort);
17
23
  try {
18
24
  if (incoming.url === '/healthz')
19
- return send(outgoing, Response.json({ ok: true }));
20
- const request = await webRequest(incoming, options.publicOrigin, options.maxRequestBodyBytes ?? 2 * 1024 * 1024);
25
+ return await send(outgoing, Response.json({ ok: true }), controller.signal);
26
+ const request = await webRequest(incoming, options.publicOrigin, options.maxRequestBodyBytes ?? 2 * 1024 * 1024, controller.signal);
21
27
  const pathname = new URL(request.url).pathname;
22
- const api = pathname.startsWith('/e/') && options.eventsApi
23
- ? options.eventsApi
24
- : pathname.startsWith('/v1/machine/') ? options.machineApi : options.controlApi;
25
- await send(outgoing, await api(request));
28
+ const api = pathname.startsWith('/.well-known/agent-skills/') && options.publicSkillApi
29
+ ? options.publicSkillApi
30
+ : pathname.startsWith('/e/') && options.eventsApi
31
+ ? options.eventsApi
32
+ : pathname.startsWith('/v1/machine/') ? options.machineApi : options.controlApi;
33
+ await send(outgoing, await api(request), controller.signal);
26
34
  }
27
35
  catch (error) {
36
+ if (controller.signal.aborted)
37
+ return;
28
38
  log('request.failed', { error: safe(error) });
39
+ if (outgoing.headersSent)
40
+ return outgoing.destroy();
29
41
  const status = error instanceof HostRequestError ? error.status : 500;
30
42
  await send(outgoing, Response.json({
31
43
  error: status === 500 ? 'Automations service failed' : error instanceof Error ? error.message : String(error),
32
- }, { status }));
44
+ }, { status }), controller.signal);
45
+ }
46
+ finally {
47
+ outgoing.removeListener('close', abort);
48
+ requests.delete(controller);
33
49
  }
34
50
  });
35
51
  return {
36
52
  server,
37
53
  async close() {
38
54
  clearInterval(timer);
55
+ for (const request of requests)
56
+ request.abort();
39
57
  await scheduling;
40
58
  await new Promise((resolve, reject) => server.close((error) => error ? reject(error) : resolve()));
41
59
  },
42
60
  };
43
61
  }
44
- async function webRequest(request, publicOrigin, maximumBodyBytes) {
62
+ async function webRequest(request, publicOrigin, maximumBodyBytes, signal) {
45
63
  const chunks = [];
46
64
  let size = 0;
47
65
  for await (const value of request) {
@@ -55,6 +73,7 @@ async function webRequest(request, publicOrigin, maximumBodyBytes) {
55
73
  return new Request(publicResourceRequestUrl(publicOrigin, request.url ?? '/'), {
56
74
  method: request.method ?? 'GET',
57
75
  headers: request.headers,
76
+ signal,
58
77
  ...(body.length ? { body } : {}),
59
78
  });
60
79
  }
@@ -65,8 +84,30 @@ class HostRequestError extends Error {
65
84
  this.status = status;
66
85
  }
67
86
  }
68
- async function send(response, source) {
87
+ async function send(response, source, signal) {
69
88
  response.writeHead(source.status, Object.fromEntries(source.headers));
70
- response.end(Buffer.from(await source.arrayBuffer()));
89
+ response.flushHeaders();
90
+ const reader = source.body?.getReader();
91
+ const abort = () => {
92
+ void reader?.cancel().catch(() => { });
93
+ response.destroy();
94
+ };
95
+ signal.addEventListener('abort', abort, { once: true });
96
+ try {
97
+ signal.throwIfAborted();
98
+ while (reader) {
99
+ const { value, done } = await reader.read();
100
+ if (done)
101
+ break;
102
+ if (!response.write(value))
103
+ await once(response, 'drain', { signal });
104
+ }
105
+ response.end();
106
+ }
107
+ finally {
108
+ signal.removeEventListener('abort', abort);
109
+ await reader?.cancel().catch(() => { });
110
+ reader?.releaseLock();
111
+ }
71
112
  }
72
113
  const safe = (error) => (error instanceof Error ? error.message : String(error)).slice(0, 500);
@@ -0,0 +1,2 @@
1
+ /** Only the two packaged public artifacts are addressable; no arbitrary file reads. */
2
+ export declare function createPublicSkillApi(directory?: URL): (request: Request) => Promise<Response>;
@@ -0,0 +1,33 @@
1
+ import { readFileSync } from 'node:fs';
2
+ const prefix = '/.well-known/agent-skills/';
3
+ /** Only the two packaged public artifacts are addressable; no arbitrary file reads. */
4
+ export function createPublicSkillApi(directory = new URL('../skills/', import.meta.url)) {
5
+ const index = readFileSync(new URL('index.json', directory));
6
+ const entry = JSON.parse(index.toString('utf8')).skills[0];
7
+ const filename = entry.url.replace(/^\.\//, '');
8
+ if (!/^amalgm-automations\.[a-f0-9]{64}\.tgz$/.test(filename))
9
+ throw new Error('Invalid skill artifact');
10
+ const files = new Map([
11
+ [`${prefix}index.json`, { bytes: index, type: 'application/json', cache: 'no-cache' }],
12
+ [`${prefix}${filename}`, {
13
+ bytes: readFileSync(new URL(filename, directory)), type: 'application/gzip',
14
+ cache: 'public, max-age=31536000, immutable',
15
+ }],
16
+ ]);
17
+ return async (request) => {
18
+ const file = files.get(new URL(request.url).pathname);
19
+ if (!file)
20
+ return new Response('Not found', { status: 404 });
21
+ if (request.method !== 'GET' && request.method !== 'HEAD') {
22
+ return new Response('Method not allowed', { status: 405, headers: { allow: 'GET, HEAD' } });
23
+ }
24
+ return new Response(request.method === 'HEAD' ? null : new Uint8Array(file.bytes), {
25
+ headers: {
26
+ 'content-type': file.type,
27
+ 'content-length': String(file.bytes.byteLength),
28
+ 'cache-control': file.cache,
29
+ 'x-content-type-options': 'nosniff',
30
+ },
31
+ });
32
+ };
33
+ }
@@ -0,0 +1 @@
1
+ {"$schema":"https://schemas.agentskills.io/discovery/0.2.0/schema.json","skills":[{"name":"amalgm-automations","description":"Set up, operate, and troubleshoot Amalgm Automations through MCP or the amalgm automations CLI. Use for installation, Google sign-in, scheduled or webhook workflows, Run Now, run history, and Automations support.","type":"archive","url":"./amalgm-automations.5e4e14f0ace632c8383cd014e1f0f34b24ec0d5c5600132a840abc4c313b31d4.tgz","digest":"sha256:5e4e14f0ace632c8383cd014e1f0f34b24ec0d5c5600132a840abc4c313b31d4"}]}
@@ -8,6 +8,6 @@ export interface AutomationCliIo extends AutomationCliInputPorts {
8
8
  stdout?: Output;
9
9
  stderr?: Output;
10
10
  }
11
- export declare const automationCliHelp = "amalgm automations \u2014 durable automation control for agents\n\nUsage:\n amalgm automations <command> [--input JSON | --file PATH | --stdin]\n amalgm-automations <command> [--input JSON | --file PATH | --stdin]\n\nCommands (identical to the Automations MCP task surface):\n create Create one complete definition\n list List the user's automations\n get Get one complete definition and optional run history\n update Apply grouped definition changes\n delete Delete current configuration; run history remains\n run-now Admit one durable manual run\n\nInput is one JSON object with the corresponding MCP tool's fields. Commands\nwithout options receive {}. Output is one JSON success or error envelope.\nUse --stdin when input contains credentials such as a webhook signing secret.\n\nWorkflow step lanes:\n version 1 action-only: {\"id\":\"...\",\"actionId\":\"product.action\",\"input\":{...}}\n version 2 action, command, or script steps:\n command {\"id\":\"...\",\"kind\":\"command\",\"command\":\"codex\",\"args\":[\"exec\",\"...\"],\"cwd\":\"/absolute/path\"}\n script {\"id\":\"...\",\"kind\":\"script\",\"runtime\":\"shell|node|python\",\"source\":\"...\",\"cwd\":\"/absolute/path\"}\n";
11
+ export declare const automationCliHelp = "amalgm automations \u2014 durable automation control for agents\n\nUsage:\n amalgm automations <command> [--input JSON | --file PATH | --stdin]\n amalgm-automations <command> [--input JSON | --file PATH | --stdin]\n\nCommands (identical to the Automations MCP task surface):\n create Create one complete definition\n list List the user's automations\n get Get one complete definition and optional run history\n update Apply grouped definition changes\n delete Delete current configuration; run history remains\n run-now Admit one durable manual run\n\nInput is one JSON object with the corresponding MCP tool's fields. Commands\nwithout options receive {}. Output is one JSON success or error envelope.\nUse --stdin when input contains credentials such as a webhook signing secret.\n\nSetup and support:\n Already connected? Reuse the running Amalgm Shell.\n First login: amalgm login --no-open\n Open the printed link, sign in with Google, and approve this computer.\n Keep the login process running; it hosts Shell after approval.\n Check: amalgm status --user EMAIL\n List local accounts: amalgm status --all\n Resume a registered computer: amalgm run --user EMAIL\n Verify access: amalgm automations list --user EMAIL --input '{\"limit\":1}'\n No local installation? https://amalgm.ai/setup\n Support: aayush@amalgm.ai\n Never copy private connection credentials into a command or support email.\n\nExternal agent MCP setup (after Shell login):\n amalgm automations mcp --user EMAIL\n Configure your client to launch this stdio command. No credential fields.\n\nWorkflow step lanes:\n version 1 action-only: {\"id\":\"...\",\"actionId\":\"product.action\",\"input\":{...}}\n version 2 action, command, or script steps:\n command {\"id\":\"...\",\"kind\":\"command\",\"command\":\"codex\",\"args\":[\"exec\",\"...\"],\"cwd\":\"/absolute/path\"}\n script {\"id\":\"...\",\"kind\":\"script\",\"runtime\":\"shell|node|python\",\"source\":\"...\",\"cwd\":\"/absolute/path\"}\n";
12
12
  export declare function runAutomationCli(argv: string[], backend: AutomationCrud | AutomationCommands, io?: AutomationCliIo): Promise<number>;
13
13
  export {};
@@ -20,6 +20,23 @@ Input is one JSON object with the corresponding MCP tool's fields. Commands
20
20
  without options receive {}. Output is one JSON success or error envelope.
21
21
  Use --stdin when input contains credentials such as a webhook signing secret.
22
22
 
23
+ Setup and support:
24
+ Already connected? Reuse the running Amalgm Shell.
25
+ First login: amalgm login --no-open
26
+ Open the printed link, sign in with Google, and approve this computer.
27
+ Keep the login process running; it hosts Shell after approval.
28
+ Check: amalgm status --user EMAIL
29
+ List local accounts: amalgm status --all
30
+ Resume a registered computer: amalgm run --user EMAIL
31
+ Verify access: amalgm automations list --user EMAIL --input '{"limit":1}'
32
+ No local installation? https://amalgm.ai/setup
33
+ Support: aayush@amalgm.ai
34
+ Never copy private connection credentials into a command or support email.
35
+
36
+ External agent MCP setup (after Shell login):
37
+ amalgm automations mcp --user EMAIL
38
+ Configure your client to launch this stdio command. No credential fields.
39
+
23
40
  Workflow step lanes:
24
41
  version 1 action-only: {"id":"...","actionId":"product.action","input":{...}}
25
42
  version 2 action, command, or script steps:
@@ -22,7 +22,6 @@ export function triggerOperations(context) {
22
22
  await context.exists(automationId);
23
23
  const parsed = parseCreateScheduleTrigger(input);
24
24
  const timezone = parsed.timezone || 'UTC';
25
- schedule(parsed.cron, timezone);
26
25
  if (parsed.id)
27
26
  await unique(automationId, parsed.id);
28
27
  return repository.createScheduleTrigger(principal.userId, automationId, {
@@ -19,6 +19,7 @@ export { SupabaseMachineRunRepository, type MachineRpcClient } from './supabase-
19
19
  export { createMachineRuns } from './machine.js';
20
20
  export type { AutomationMachinePrincipal, ClaimedAutomationRun, MachineRunRepository, MachineRuns, MachineRunUpdate, } from './machine.js';
21
21
  export { createMachineRunsApi } from './machine-http.js';
22
+ export { createMachineRunNotifications, type MachineRunNotifications } from './machine-notifications.js';
22
23
  export { createMachineRunsClient, type AutomationRequestHeaders } from './machine-client.js';
23
24
  export { AutomationRunExecutor, automationPlan, type AutomationActionPort, type AutomationProcessExecution, type AutomationProcessPort, type AutomationRunExecutorOptions, } from './executor.js';
24
25
  export { createNodeAutomationProcessHost, type NodeAutomationProcessHostOptions, } from './node-process-host.js';
package/dist/src/index.js CHANGED
@@ -24,6 +24,7 @@ export { SupabaseStore } from './supabase-store.js';
24
24
  export { SupabaseMachineRunRepository } from './supabase-machine.js';
25
25
  export { createMachineRuns } from './machine.js';
26
26
  export { createMachineRunsApi } from './machine-http.js';
27
+ export { createMachineRunNotifications } from './machine-notifications.js';
27
28
  export { createMachineRunsClient } from './machine-client.js';
28
29
  export { AutomationRunExecutor, automationPlan, } from './executor.js';
29
30
  export { createNodeAutomationProcessHost, } from './node-process-host.js';
@@ -5,4 +5,6 @@ export declare function createMachineRunsClient(options: {
5
5
  readonly headers: AutomationRequestHeaders;
6
6
  readonly fetch?: typeof globalThis.fetch;
7
7
  readonly requestTimeoutMs?: number;
8
- }): MachineRuns;
8
+ }): MachineRuns & {
9
+ notifications(signal: AbortSignal): AsyncIterable<void>;
10
+ };
@@ -1,4 +1,5 @@
1
1
  import { AutomationError } from './errors.js';
2
+ import { machineEventStream } from './machine-event-stream.js';
2
3
  export function createMachineRunsClient(options) {
3
4
  const baseUrl = options.baseUrl.replace(/\/$/, '');
4
5
  const fetch = options.fetch ?? globalThis.fetch;
@@ -23,6 +24,11 @@ export function createMachineRunsClient(options) {
23
24
  return payload;
24
25
  };
25
26
  return Object.freeze({
27
+ notifications(signal) {
28
+ return machineEventStream({
29
+ url: `${baseUrl}/v1/machine/runs/notifications`, headers: options.headers, fetch, signal,
30
+ });
31
+ },
26
32
  async claim(input = {}) {
27
33
  const result = await request('/v1/machine/runs/claim', 'POST', input);
28
34
  return result.runs;
@@ -0,0 +1,8 @@
1
+ import type { AutomationRequestHeaders } from './machine-client.js';
2
+ /** A wakeup stream carries no run data; every reconnect gets fresh authorization. */
3
+ export declare function machineEventStream(options: {
4
+ readonly url: string;
5
+ readonly headers: AutomationRequestHeaders;
6
+ readonly fetch: typeof globalThis.fetch;
7
+ readonly signal: AbortSignal;
8
+ }): AsyncGenerator<void>;
@@ -0,0 +1,58 @@
1
+ import { AutomationError } from './errors.js';
2
+ /** A wakeup stream carries no run data; every reconnect gets fresh authorization. */
3
+ export async function* machineEventStream(options) {
4
+ const connection = new AbortController();
5
+ const signal = AbortSignal.any([options.signal, connection.signal]);
6
+ let rejectAborted;
7
+ const aborted = new Promise((_, reject) => { rejectAborted = reject; });
8
+ const abort = () => rejectAborted(signal.reason);
9
+ signal.addEventListener('abort', abort, { once: true });
10
+ let timeout = setTimeout(() => connection.abort(new Error('Notification connection timed out')), 15_000);
11
+ timeout.unref();
12
+ let reader;
13
+ try {
14
+ const headers = await Promise.race([
15
+ Promise.resolve().then(() => { signal.throwIfAborted(); return options.headers('GET', options.url); }),
16
+ aborted,
17
+ ]);
18
+ signal.throwIfAborted();
19
+ const response = await options.fetch(options.url, {
20
+ headers: { ...headers, accept: 'text/event-stream' }, signal,
21
+ });
22
+ if (!response.ok)
23
+ throw new AutomationError('notifications_unavailable', `Automations notifications returned ${response.status}`, response.status);
24
+ if (!response.headers.get('content-type')?.startsWith('text/event-stream') || !response.body) {
25
+ throw new Error('Expected an Automations notification stream');
26
+ }
27
+ reader = response.body.getReader();
28
+ const decoder = new TextDecoder();
29
+ let pending = '';
30
+ while (!signal.aborted) {
31
+ clearTimeout(timeout);
32
+ timeout = setTimeout(() => connection.abort(new Error('Notification connection went silent')), 75_000);
33
+ timeout.unref();
34
+ const { value, done } = await reader.read();
35
+ if (done)
36
+ throw new Error('Automations notification connection closed');
37
+ pending += decoder.decode(value, { stream: true });
38
+ let boundary;
39
+ while ((boundary = /\r?\n\r?\n/.exec(pending))) {
40
+ const frame = pending.slice(0, boundary.index);
41
+ pending = pending.slice(boundary.index + boundary[0].length);
42
+ if (frame.length > 16_384)
43
+ throw new Error('Automations notification is too large');
44
+ if (/^event: ?(?:ready|wake)\r?$/m.test(frame))
45
+ yield;
46
+ }
47
+ if (pending.length > 16_384)
48
+ throw new Error('Automations notification is too large');
49
+ }
50
+ }
51
+ finally {
52
+ clearTimeout(timeout);
53
+ signal.removeEventListener('abort', abort);
54
+ connection.abort();
55
+ await reader?.cancel().catch(() => { });
56
+ reader?.releaseLock();
57
+ }
58
+ }
@@ -1,5 +1,7 @@
1
1
  import type { AutomationMachinePrincipal, MachineRuns } from './machine.js';
2
+ import type { MachineRunNotifications } from './machine-notifications.js';
2
3
  export declare function createMachineRunsApi(options: {
3
4
  readonly authenticate: (request: Request) => Promise<AutomationMachinePrincipal>;
4
5
  readonly runsFor: (principal: AutomationMachinePrincipal) => MachineRuns;
6
+ readonly notifications?: Pick<MachineRunNotifications, 'open'>;
5
7
  }): (request: Request) => Promise<Response>;
@@ -7,7 +7,13 @@ export function createMachineRunsApi(options) {
7
7
  if (parts[0] !== 'v1' || parts[1] !== 'machine' || parts[2] !== 'runs') {
8
8
  return json(404, { error: 'Not found' });
9
9
  }
10
- const runs = options.runsFor(await options.authenticate(request));
10
+ const principal = await options.authenticate(request);
11
+ if (parts[3] === 'notifications' && parts.length === 4 && request.method === 'GET') {
12
+ if (!options.notifications)
13
+ throw new AutomationError('unavailable', 'Notifications unavailable', 503);
14
+ return options.notifications.open(principal, request.signal);
15
+ }
16
+ const runs = options.runsFor(principal);
11
17
  if (parts[3] === 'claim' && parts.length === 4 && request.method === 'POST') {
12
18
  return json(200, { runs: await runs.claim(await body(request)) });
13
19
  }
@@ -18,7 +24,7 @@ export function createMachineRunsApi(options) {
18
24
  }
19
25
  catch (error) {
20
26
  if (error instanceof AutomationError)
21
- return json(error.code === 'validation' ? 400 : 403, {
27
+ return json(error.status ?? (error.code === 'validation' ? 400 : 403), {
22
28
  error: error.message, code: error.code,
23
29
  });
24
30
  const code = error instanceof Error && 'code' in error ? String(error.code) : 'internal';
@@ -0,0 +1,15 @@
1
+ import type { AutomationMachinePrincipal } from './machine.js';
2
+ /** Ephemeral wakeups only. Supabase remains the queue and the eligibility clock. */
3
+ export declare function createMachineRunNotifications(options: {
4
+ readonly wakeDelay: (userId: string, computerId: string) => Promise<number | null>;
5
+ readonly heartbeatMs?: number;
6
+ readonly maxStreamMs?: number;
7
+ readonly log?: (event: string, details: Readonly<Record<string, unknown>>) => void;
8
+ }): Readonly<{
9
+ connected(): void;
10
+ disconnected: () => void;
11
+ close: () => void;
12
+ changed(userId: string, computerId: string): void;
13
+ open(principal: AutomationMachinePrincipal, signal: AbortSignal): Response;
14
+ }>;
15
+ export type MachineRunNotifications = ReturnType<typeof createMachineRunNotifications>;