From 16d2b4500b2d61732b6ac38abec490ec78e27197 Mon Sep 17 00:00:00 2001 From: bardonadam Date: Sun, 22 Mar 2026 15:04:58 +0100 Subject: [PATCH 1/7] feat: add node stream wrapper and docs --- README.md | 214 ++++++++++++++++++++++++++++++++++++---- src/ActivitySmith.ts | 48 +++++++++ tests/resources.test.js | 72 ++++++++++++++ tests/smoke.test.js | 11 ++- 4 files changed, 324 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 94326e6..b3d8046 100644 --- a/README.md +++ b/README.md @@ -111,27 +111,196 @@ await activitysmith.notifications.send({ ## Live Activities -Live Activities come in two UI types, but the lifecycle stays the same: -start the activity, keep the returned `activity_id`, update it as state -changes, then end it when the work is done. +ActivitySmith supports two ways to drive Live Activities: -- `segmented_progress`: best for jobs tracked in steps -- `progress`: best for jobs tracked as a percentage or numeric range +- Recommended: stream updates with `activitysmith.liveActivities.stream(...)` +- Advanced: manual lifecycle control with `start`, `update`, and `end` -### Shared flow +Use stream updates when you want the easiest, stateless flow. You don't need to +store `activity_id` or manage lifecycle state yourself. Send the latest state +for a stable `streamKey` and ActivitySmith will start or update the Live +Activity for you. When the tracked process is over, call `endStream(...)`. + +Use the manual lifecycle methods when you need direct control over a specific +Live Activity instance. + +Live Activity UI types: + +- `metrics`: best for live operational stats like server CPU and memory, queue depth, or replica lag +- `segmented_progress`: best for step-based workflows like deployments, backups, and ETL pipelines +- `progress`: best for continuous jobs like uploads, reindexes, and long-running migrations tracked as a percentage + +### Recommended: Stream updates + +Use a stable `streamKey` to identify the system or workflow you are tracking, +such as a server, deployment, build pipeline, cron job, or charging session. +This is especially useful for cron jobs and other scheduled tasks where you do +not want to store `activity_id` between runs. + +#### Metrics + +```ts +const status = await activitysmith.liveActivities.stream("prod-web-1", { + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 9, unit: "%" }, + { label: "MEM", value: 45, unit: "%" }, + ], + }, +}); +``` + +#### Segmented progress + +```ts +await activitysmith.liveActivities.stream("nightly-backup", { + content_state: { + title: "Nightly Backup", + subtitle: "upload archive", + type: "segmented_progress", + number_of_steps: 4, + current_step: 2, + }, +}); +``` + +#### Progress + +```ts +await activitysmith.liveActivities.stream("search-reindex", { + content_state: { + title: "Search Reindex", + subtitle: "catalog-v2", + type: "progress", + percentage: 42, + }, +}); +``` + +Call `stream(...)` again with the same `streamKey` whenever the state changes. + +#### End a stream + +Use this when the tracked thing is finished and you no longer want the Live +Activity on devices. `content_state` is optional here; include it if you want +to end the stream with a final state. + +```ts +await activitysmith.liveActivities.endStream("prod-web-1", { + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 7, unit: "%" }, + { label: "MEM", value: 38, unit: "%" }, + ], + }, +}); +``` + +If you later send another `stream(...)` request with the same `streamKey`, +ActivitySmith starts a new Live Activity for that stream again. + +Stream responses include an `operation` field: + +- `started`: ActivitySmith started a new Live Activity for this `streamKey` +- `updated`: ActivitySmith updated the current Live Activity +- `rotated`: ActivitySmith ended the previous Live Activity and started a new one +- `noop`: the incoming state matched the current state, so no update was sent +- `paused`: the stream is paused, so no Live Activity was started or updated +- `ended`: returned by `endStream(...)` after the stream is ended + +### Advanced: Manual lifecycle control + +Use these methods when you want to manage the Live Activity lifecycle yourself. + +#### Shared flow 1. Call `activitysmith.liveActivities.start(...)`. 2. Save the returned `activity_id`. 3. Call `activitysmith.liveActivities.update(...)` as progress changes. 4. Call `activitysmith.liveActivities.end(...)` when the work is finished. +### Metrics Type + +Use `metrics` when you want to keep a small set of live stats visible, such as +server health, queue pressure, or database load. + +#### Start + +

+ Metrics start example +

+ +```ts +const start = await activitysmith.liveActivities.start({ + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 9, unit: "%" }, + { label: "MEM", value: 45, unit: "%" }, + ], + }, +}); + +const activityId = start.activity_id; +``` + +#### Update + +

+ Metrics update example +

+ +```ts +await activitysmith.liveActivities.update({ + activity_id: activityId, + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 76, unit: "%" }, + { label: "MEM", value: 52, unit: "%" }, + ], + }, +}); +``` + +#### End + +

+ Metrics end example +

+ +```ts +await activitysmith.liveActivities.end({ + activity_id: activityId, + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 7, unit: "%" }, + { label: "MEM", value: 38, unit: "%" }, + ], + auto_dismiss_minutes: 2, + }, +}); +``` + ### Segmented Progress Type Use `segmented_progress` when progress is easier to follow as steps instead of a raw percentage. It fits jobs like backups, deployments, ETL pipelines, and -checklists where "step 2 of 3" is more useful than "67%". -`number_of_steps` is dynamic, so you can increase or decrease it later if the -workflow changes. +checklists where "step 2 of 3" is more useful than "67%". `number_of_steps` is +dynamic, so you can increase or decrease it later if the workflow changes. #### Start @@ -149,7 +318,6 @@ const start = await activitysmith.liveActivities.start({ type: "segmented_progress", color: "yellow", }, - channels: ["devs", "ops"], // Optional }); const activityId = start.activity_id; @@ -257,25 +425,27 @@ await activitysmith.liveActivities.end({ Just like Actionable Push Notifications, Live Activities can have a button that opens provided URL in a browser or triggers a webhook. Webhooks are executed by the ActivitySmith backend. +#### Open URL action +

- Live Activity with action + Metrics Live Activity with action

-#### Open URL action - ```ts const start = await activitysmith.liveActivities.start({ content_state: { - title: "Deploying payments-api", - subtitle: "Running database migrations", - number_of_steps: 5, - current_step: 3, - type: "segmented_progress", + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 76, unit: "%" }, + { label: "MEM", value: 52, unit: "%" }, + ], }, action: { - title: "Open Workflow", + title: "Open Dashboard", type: "open_url", - url: "https://github.com/acme/payments-api/actions/runs/1234567890", + url: "https://ops.example.com/servers/prod-web-1", }, }); @@ -284,6 +454,10 @@ const activityId = start.activity_id; #### Webhook action +

+ Live Activity with action +

+ ```ts await activitysmith.liveActivities.update({ activity_id: activityId, diff --git a/src/ActivitySmith.ts b/src/ActivitySmith.ts index 0c431b0..d3bb5d9 100644 --- a/src/ActivitySmith.ts +++ b/src/ActivitySmith.ts @@ -11,10 +11,15 @@ type SendInitOverrides = Parameters[0]["liveActivityStartRequest"]; type UpdateRequestBody = Parameters[0]["liveActivityUpdateRequest"]; type EndRequestBody = Parameters[0]["liveActivityEndRequest"]; +type StreamRequestBody = + Parameters[0]["liveActivityStreamRequest"]; +type StreamDeleteRequestBody = + Parameters[0]["liveActivityStreamDeleteRequest"]; type LiveInitOverrides = Parameters[1]; type ChannelTargetInput = { channels?: string[] }; type PushSendRequest = PushRequestBody & { channels?: string[] }; type LiveStartSendRequest = StartRequestBody & { channels?: string[] }; +type LiveStreamSendRequest = StreamRequestBody & { channels?: string[] }; function withTargetChannels( request: T & { channels?: string[] }, @@ -108,6 +113,31 @@ export class LiveActivitiesResource { return this.api.endLiveActivity({ liveActivityEndRequest: request }, initOverrides); } + stream(streamKey: string, request: LiveStreamSendRequest, initOverrides?: LiveInitOverrides) { + return this.api.reconcileLiveActivityStream( + { + streamKey, + liveActivityStreamRequest: withTargetChannels(request), + }, + initOverrides, + ); + } + + endStream( + streamKey: string, + request?: StreamDeleteRequestBody, + initOverrides?: LiveInitOverrides, + ) { + if (request) { + return this.api.endLiveActivityStream( + { streamKey, liveActivityStreamDeleteRequest: request }, + initOverrides, + ); + } + + return this.api.endLiveActivityStream({ streamKey }, initOverrides); + } + // Backward-compatible aliases. startLiveActivity(...args: Parameters) { return this.api.startLiveActivity(...args); @@ -121,6 +151,14 @@ export class LiveActivitiesResource { return this.api.endLiveActivity(...args); } + reconcileLiveActivityStream(...args: Parameters) { + return this.api.reconcileLiveActivityStream(...args); + } + + endLiveActivityStream(...args: Parameters) { + return this.api.endLiveActivityStream(...args); + } + startLiveActivityRaw(...args: Parameters) { return this.api.startLiveActivityRaw(...args); } @@ -132,6 +170,16 @@ export class LiveActivitiesResource { endLiveActivityRaw(...args: Parameters) { return this.api.endLiveActivityRaw(...args); } + + reconcileLiveActivityStreamRaw( + ...args: Parameters + ) { + return this.api.reconcileLiveActivityStreamRaw(...args); + } + + endLiveActivityStreamRaw(...args: Parameters) { + return this.api.endLiveActivityStreamRaw(...args); + } } export class ActivitySmith { diff --git a/tests/resources.test.js b/tests/resources.test.js index 7b1a7e1..d6fa38d 100644 --- a/tests/resources.test.js +++ b/tests/resources.test.js @@ -228,6 +228,78 @@ describe("resource wrappers", () => { expect(startSpy).toHaveBeenCalledWith({ liveActivityStartRequest: payload }, undefined); }); + it("wraps live activity stream payloads for short methods", async () => { + const ActivitySmith = require("../dist/src/index.js"); + const generated = require("../dist/generated/index.js"); + + const streamSpy = vi + .spyOn(generated.LiveActivitiesApi.prototype, "reconcileLiveActivityStream") + .mockResolvedValue({ operation: "started", stream_key: "prod-web-1" }); + const endStreamSpy = vi + .spyOn(generated.LiveActivitiesApi.prototype, "endLiveActivityStream") + .mockResolvedValue({ operation: "ended", stream_key: "prod-web-1" }); + + const client = new ActivitySmith({ apiKey: "test" }); + await client.liveActivities.stream("prod-web-1", { + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 9, unit: "%" }, + { label: "MEM", value: 45, unit: "%" }, + ], + }, + channels: ["ops"], + }); + await client.liveActivities.endStream("prod-web-1"); + + expect(streamSpy).toHaveBeenCalledWith( + { + streamKey: "prod-web-1", + liveActivityStreamRequest: { + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [ + { label: "CPU", value: 9, unit: "%" }, + { label: "MEM", value: 45, unit: "%" }, + ], + }, + target: { channels: ["ops"] }, + }, + }, + undefined, + ); + expect(endStreamSpy).toHaveBeenCalledWith({ streamKey: "prod-web-1" }, undefined); + }); + + it("keeps long stream aliases working", async () => { + const ActivitySmith = require("../dist/src/index.js"); + const generated = require("../dist/generated/index.js"); + + const streamSpy = vi + .spyOn(generated.LiveActivitiesApi.prototype, "reconcileLiveActivityStream") + .mockResolvedValue({ operation: "started", stream_key: "prod-web-1" }); + + const client = new ActivitySmith({ apiKey: "test" }); + const request = { + streamKey: "prod-web-1", + liveActivityStreamRequest: { + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [{ label: "CPU", value: 9, unit: "%" }], + }, + }, + }; + + await client.liveActivities.reconcileLiveActivityStream(request); + expect(streamSpy).toHaveBeenCalledWith(request); + }); + it("passes through live activity actions for short methods", async () => { const ActivitySmith = require("../dist/src/index.js"); const generated = require("../dist/generated/index.js"); diff --git a/tests/smoke.test.js b/tests/smoke.test.js index e515448..3975e8e 100644 --- a/tests/smoke.test.js +++ b/tests/smoke.test.js @@ -49,9 +49,18 @@ describe("smoke", () => { type: "segmented_progress", }, }); + await client.liveActivities.stream("prod-web-1", { + content_state: { + title: "Server Health", + subtitle: "prod-web-1", + type: "metrics", + metrics: [{ label: "CPU", value: 9, unit: "%" }], + }, + }); - expect(fetchSpy).toHaveBeenCalledTimes(2); + expect(fetchSpy).toHaveBeenCalledTimes(3); expect(String(fetchSpy.mock.calls[0][0])).toContain("/push-notification"); expect(String(fetchSpy.mock.calls[1][0])).toContain("/live-activity/start"); + expect(String(fetchSpy.mock.calls[2][0])).toContain("/live-activity/stream/prod-web-1"); }); }); From 6ccbad49cafaa656b7907363aed5d497c7aa5158 Mon Sep 17 00:00:00 2001 From: bardonadam Date: Sun, 22 Mar 2026 15:09:50 +0100 Subject: [PATCH 2/7] docs: add live activities header image --- README.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/README.md b/README.md index b3d8046..198398e 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,10 @@ await activitysmith.notifications.send({ ## Live Activities +

+ Live Activities example +

+ ActivitySmith supports two ways to drive Live Activities: - Recommended: stream updates with `activitysmith.liveActivities.stream(...)` From ab5b9a38cd990502873c081d816e3fa3b60f134b Mon Sep 17 00:00:00 2001 From: bardonadam Date: Sun, 22 Mar 2026 15:13:26 +0100 Subject: [PATCH 3/7] docs: align segmented progress examples to 3 steps --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 198398e..bf1d6d2 100644 --- a/README.md +++ b/README.md @@ -165,7 +165,7 @@ await activitysmith.liveActivities.stream("nightly-backup", { title: "Nightly Backup", subtitle: "upload archive", type: "segmented_progress", - number_of_steps: 4, + number_of_steps: 3, current_step: 2, }, }); @@ -339,7 +339,7 @@ await activitysmith.liveActivities.update({ content_state: { title: "Nightly database backup", subtitle: "upload archive", - number_of_steps: 4, + number_of_steps: 3, current_step: 2, }, }); @@ -357,8 +357,8 @@ await activitysmith.liveActivities.end({ content_state: { title: "Nightly database backup", subtitle: "verify restore", - number_of_steps: 4, - current_step: 4, + number_of_steps: 3, + current_step: 3, auto_dismiss_minutes: 2, }, }); From 89e749b8ec2fc66317c9b9271fa6f5eb07926be2 Mon Sep 17 00:00:00 2001 From: bardonadam Date: Sun, 22 Mar 2026 15:16:14 +0100 Subject: [PATCH 4/7] docs: add stream example screenshots --- README.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/README.md b/README.md index bf1d6d2..1bcdc2e 100644 --- a/README.md +++ b/README.md @@ -143,6 +143,10 @@ not want to store `activity_id` between runs. #### Metrics +

+ Metrics stream example +

+ ```ts const status = await activitysmith.liveActivities.stream("prod-web-1", { content_state: { @@ -159,6 +163,10 @@ const status = await activitysmith.liveActivities.stream("prod-web-1", { #### Segmented progress +

+ Segmented progress stream example +

+ ```ts await activitysmith.liveActivities.stream("nightly-backup", { content_state: { @@ -173,6 +181,10 @@ await activitysmith.liveActivities.stream("nightly-backup", { #### Progress +

+ Progress stream example +

+ ```ts await activitysmith.liveActivities.stream("search-reindex", { content_state: { From a060c2683ca02371262fa14c2020ee6dd47076ea Mon Sep 17 00:00:00 2001 From: bardonadam Date: Sun, 22 Mar 2026 15:24:31 +0100 Subject: [PATCH 5/7] docs: swap progress stream screenshot --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1bcdc2e..1c85632 100644 --- a/README.md +++ b/README.md @@ -182,7 +182,7 @@ await activitysmith.liveActivities.stream("nightly-backup", { #### Progress

- Progress stream example + Progress stream example

```ts From 0de324003dfadcee6d167543ee640a53ee967365 Mon Sep 17 00:00:00 2001 From: bardonadam Date: Sun, 22 Mar 2026 15:37:40 +0100 Subject: [PATCH 6/7] docs: refine end stream wording --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 1c85632..e70805f 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ Call `stream(...)` again with the same `streamKey` whenever the state changes. #### End a stream -Use this when the tracked thing is finished and you no longer want the Live +Use this when the tracked process is finished and you no longer want the Live Activity on devices. `content_state` is optional here; include it if you want to end the stream with a final state. From 9cd4d12dfef15a06601e1150cbc6f0092b4113f2 Mon Sep 17 00:00:00 2001 From: bardonadam Date: Sun, 22 Mar 2026 15:40:20 +0100 Subject: [PATCH 7/7] chore: bump node sdk version to 1.1.0 --- package-lock.json | 4 ++-- package.json | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/package-lock.json b/package-lock.json index 1f95263..e1e7b50 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "activitysmith", - "version": "1.0.0", + "version": "1.1.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "activitysmith", - "version": "1.0.0", + "version": "1.1.0", "license": "MIT", "devDependencies": { "typescript": "^5.3.3", diff --git a/package.json b/package.json index 24fee17..1e38ac6 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "activitysmith", - "version": "1.0.0", + "version": "1.1.0", "description": "Official ActivitySmith Node.js SDK", "keywords": [ "activitysmith",