diff --git a/README.md b/README.md
index 94326e6..e70805f 100644
--- a/README.md
+++ b/README.md
@@ -111,27 +111,212 @@ 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:
+
+- Recommended: stream updates with `activitysmith.liveActivities.stream(...)`
+- Advanced: manual lifecycle control with `start`, `update`, and `end`
+
+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: 3,
+ 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 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.
+
+```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
-- `segmented_progress`: best for jobs tracked in steps
-- `progress`: best for jobs tracked as a percentage or numeric range
+Use these methods when you want to manage the Live Activity lifecycle yourself.
-### Shared flow
+#### 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
+
+
+
+
+
+```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
+
+
+
+
+
+```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
+
+
+
+
+
+```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 +334,6 @@ const start = await activitysmith.liveActivities.start({
type: "segmented_progress",
color: "yellow",
},
- channels: ["devs", "ops"], // Optional
});
const activityId = start.activity_id;
@@ -167,7 +351,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,
},
});
@@ -185,8 +369,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,
},
});
@@ -257,25 +441,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
+
-
+
-#### 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 +470,10 @@ const activityId = start.activity_id;
#### Webhook action
+
+
+
+
```ts
await activitysmith.liveActivities.update({
activity_id: activityId,
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",
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");
});
});