@uphealth/signal
v0.1.1
Published
Official Node/TypeScript SDK for the Uphealth Signal API — cued health-message streams with a receptivity score and an audience-safety verdict on every cue.
Maintainers
Readme
@uphealth/signal
The official Node / TypeScript SDK for the Uphealth Signal API — cued health-message streams. Create a stream for a patient, then advance it one cue at a time; each cue returns the next federally-sourced message the engine selects, plus an advisory receptivity score and an audience-safety verdict.
- ✅ Fully typed, zero runtime dependencies (Node 18+ global
fetch) - ✅
runStreamhelper drives the create → cue → feedback loop for you - ✅ Typed errors (
RateLimitError,ConflictError, …) with structured fields - ✅ Automatic, idempotency-safe retries with backoff
- ✅ Idempotency keys generated for you
- ✅ Forward-compatible by design (tolerates new fields and response values)
Display mode never touches PHI and needs no BAA. Keep your API key server-side — never ship it to a browser.
Install
npm install @uphealth/signalGet a free Discovery sandbox key (no credit card) at https://uphealth.us/signup — then:
export UPHEALTH_API_KEY=up_sandbox_…Quickstart
import { Signal, isStreamExhausted } from "@uphealth/signal";
const signal = new Signal(); // reads UPHEALTH_API_KEY
const stream = await signal.streams.create({ templateId: "general_wellness_daily" });
console.log(stream.firstCue.body); // the opening message
console.log(stream.firstCue.receptivity); // 0.5 for a brand-new patient
// The one rule: every cue after the first requires feedback on the prior one.
const outcome = await signal.streams.cue(stream.streamId, { responseAction: "did_it" });
if (!isStreamExhausted(outcome)) {
console.log(outcome.nextCue.body);
}Run a whole sequence
runStream creates the stream and loops the cue/feedback contract until the stream ends — so you can't accidentally trip 409 feedback_required:
const run = await signal.streams.runStream(
{ templateId: "general_wellness_daily" },
(cue) => (cue.kind === "tip" ? "will_try" : "acknowledge"),
{ maxCues: 50 },
);
console.log(`${run.transcript.length} cues, ended: ${run.endedReason}`);The decider may return a ResponseAction string or a full CueParams object (to attach a freeText note), and may be async.
Configuration
const signal = new Signal({
apiKey: process.env.UPHEALTH_API_KEY, // or UPHEALTH_API_KEY env var
baseUrl: "https://api.uphealth.us/v1/signal", // default
timeout: 30_000, // ms
maxRetries: 2, // 5xx + network retries for idempotent calls
defaultHeaders: {},
// fetch: customFetch, // inject for tests / non-global-fetch runtimes
});The four endpoints
| Method | Call | Endpoint |
|---|---|---|
| Create a stream | signal.streams.create(params) | POST /streams |
| Read a stream | signal.streams.get(streamId) | GET /streams/:id |
| Advance a stream | signal.streams.cue(streamId, feedback) | POST /streams/:id/cue |
| List sandbox topics | signal.streams.sandboxTopics() | GET /sandbox-topics |
Responses are camelCase. Every response also carries a non-enumerable .raw with the original payload, and a .meta legal/disclosure block you should not strip from buyer-facing surfaces.
Response actions
The patient's response to a cue, in cue(...)'s responseAction:
did_it · already_do · will_try · check_it · new_to_me · needed_this · know_it · acknowledge · no_response
Importable as RESPONSE_ACTIONS. The vocabulary grows over time; the SDK tolerates unknown values it receives and treats them as acknowledge-class.
Errors
Every non-2xx maps to a typed error you can branch on:
import { RateLimitError, ConflictError, ValidationError, AuthenticationError } from "@uphealth/signal";
try {
await signal.streams.cue(streamId, { responseAction: "did_it" });
} catch (err) {
if (err instanceof RateLimitError) {
// Discovery cap hit — surface the upgrade path
showUpgrade(err.upgradeUrl, err.currentCount, err.cap);
} else if (err instanceof ConflictError && err.code === "feedback_required") {
// submit feedback on the prior cue first
} else {
throw err;
}
}| Class | Status | Notable fields |
|---|---|---|
| AuthenticationError | 401 | code |
| PermissionDeniedError | 403 | requiredScope |
| NotFoundError | 404 | — |
| ConflictError | 409 | code (feedback_required | idempotency_key_conflict | stream_ended), lastCueEventId, existingEventId |
| ValidationError | 422 | details, required, incompatible |
| RateLimitError | 429 | retryAfter, cap, currentCount, window, upgradeUrl |
| ServiceUnavailableError | 503 | code |
| APIConnectionError | — | cause (network/timeout) |
All extend SignalError and carry status, code, requestId, and body.
Retries & idempotency
cue()is retry-safe: anidempotencyKeyis generated if you don't pass one, so resends return the cached next cue instead of double-advancing.- The client retries
5xxand network errors with exponential backoff + jitter, up tomaxRetries. create()is not auto-retried on ambiguous network errors (it isn't idempotent server-side yet) — to avoid double-creating.429is never auto-retried (the Discovery window resets monthly); handle it viaRateLimitError.
Using Signal from an AI agent
This SDK underpins the Uphealth MCP server, so an agent can drive a stream with the same primitives. See the MCP server for tool-based access, or call the SDK directly inside your own tool definitions.
Links
- Docs: https://uphealth.us/docs
- OpenAPI spec: https://uphealth.us/openapi/signal-v1.yaml
- Get a sandbox key: https://uphealth.us/signup
License
MIT © Anschutz Media, Inc. (Uphealth)
