·
20 min read
Write to Fresh Jots from C — `libcurl`, with or without `cJSON`
Write to Fresh Jots from C — `libcurl`, with or without `cJSON`
A spin-off from Write a note from any project, focused on C. Three flavors:
- **`libcurl` + hand-rolled JSON** — one `.c` file, ~140 lines. The recommended path for most.
- **`libcurl` + `cJSON`** — single-header JSON when you want to parse error-response `code` fields.
- **Embedded / no-libcurl** — vendor HTTPS stack (mbedTLS, ESP-IDF, lwIP) for MCU contexts.
All three hit the same `/api/v1/notes/by-filename/<name>/append` endpoint. You'll need a Fresh Jots API token (`FRESHJOTS_TOKEN`). If you don't have one, see Get & set your Fresh Jots API token.
C has no package manager and no JSON in the standard library — the snippets below are MIT-licensed; copy `freshjots.h` and `freshjots.c` into your tree and add them to your Makefile or CMakeLists. C99 with one library dependency: libcurl.
1. `libcurl` — the recommended path (C99+)
1. `libcurl` — the recommended path (C99+)
The header:
```c
/* freshjots.h */
#ifndef FRESHJOTS_H
#define FRESHJOTS_H
#include <stddef.h>
typedef struct {
char *token; /* private — do not touch directly */
} freshjots_client;
typedef enum {
FRESHJOTS_OK = 0,
FRESHJOTS_TOKEN_MISSING = 1,
FRESHJOTS_NETWORK = 2,
FRESHJOTS_API = 3,
FRESHJOTS_OOM = 4
} freshjots_status;
/* Call once at program start before any client init. Wraps curl_global_init. */
freshjots_status freshjots_global_init(void);
/* Call once at program shutdown after all clients are freed. */
void freshjots_global_cleanup(void);
/* If token is NULL, reads FRESHJOTS_TOKEN env var. Zero-initializes c. */
freshjots_status freshjots_init(freshjots_client *c, const char *token);
/* Free internal resources. Safe to call on a zero-initialized struct. */
void freshjots_free(freshjots_client *c);
/* Append text to a note (creates the note on first append).
* Writes HTTP status to *out_status when non-NULL.
* text must be valid UTF-8 with no embedded NUL bytes.
*
* Thread-safety: a single freshjots_client may be shared across threads
* calling freshjots_append concurrently (each call creates its own
* libcurl handle and reads c->token only). freshjots_free is NOT safe
* to call while another thread is in freshjots_append. */
freshjots_status freshjots_append(freshjots_client *c,
const char *filename,
const char *text,
long *out_status);
#endif /* FRESHJOTS_H */
```
The implementation:
```c
/* freshjots.c */
#include "freshjots.h"
#include <curl/curl.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define BASE_URL "https://freshjots.com/api/v1"
freshjots_status freshjots_global_init(void) {
return curl_global_init(CURL_GLOBAL_DEFAULT) == CURLE_OK
? FRESHJOTS_OK : FRESHJOTS_NETWORK;
}
void freshjots_global_cleanup(void) {
curl_global_cleanup();
}
freshjots_status freshjots_init(freshjots_client *c, const char *token) {
c->token = NULL;
if (!token) token = getenv("FRESHJOTS_TOKEN");
if (!token || !*token) return FRESHJOTS_TOKEN_MISSING;
size_t n = strlen(token) + 1;
c->token = malloc(n);
if (!c->token) return FRESHJOTS_OOM;
memcpy(c->token, token, n);
return FRESHJOTS_OK;
}
void freshjots_free(freshjots_client *c) {
if (!c) return;
free(c->token);
c->token = NULL;
}
/* RFC 3986 unreserved-set percent-encoder.
* Returns a malloc'd buffer the caller must free; NULL on OOM. */
static char *url_encode(const char *s) {
static const char hex[] = "0123456789ABCDEF";
size_t in_len = strlen(s);
if (in_len > (SIZE_MAX - 1) / 3) return NULL; /* defensive overflow guard */
char *out = malloc(in_len * 3 + 1);
if (!out) return NULL;
char *p = out;
for (size_t i = 0; i < in_len; i++) {
unsigned char ch = (unsigned char)s[i];
if ((ch >= '0' && ch <= '9') ||
(ch >= 'A' && ch <= 'Z') ||
(ch >= 'a' && ch <= 'z') ||
ch == '-' || ch == '_' || ch == '.' || ch == '~') {
*p++ = (char)ch;
} else {
*p++ = '%';
*p++ = hex[ch >> 4];
*p++ = hex[ch & 0x0F];
}
}
*p = '\0';
return out;
}
/* Build {"text": "<escaped>"} into a malloc'd buffer.
* Caller frees. NULL on OOM. The buffer is sized for worst-case escape
* (6 bytes per input char for "\u00xx") plus the wrapper, so every
* sprintf below cannot overflow. */
static char *build_body(const char *text) {
size_t text_len = strlen(text);
if (text_len > (SIZE_MAX - 16) / 6) return NULL; /* defensive overflow guard */
size_t cap = text_len * 6 + 16;
char *body = malloc(cap);
if (!body) return NULL;
char *p = body;
p += sprintf(p, "{\"text\":\"");
for (const unsigned char *q = (const unsigned char *)text; *q; q++) {
switch (*q) {
case '"': p += sprintf(p, "\\\""); break;
case '\\': p += sprintf(p, "\\\\"); break;
case '\b': p += sprintf(p, "\\b"); break;
case '\f': p += sprintf(p, "\\f"); break;
case '\n': p += sprintf(p, "\\n"); break;
case '\r': p += sprintf(p, "\\r"); break;
case '\t': p += sprintf(p, "\\t"); break;
default:
if (*q < 0x20) {
p += sprintf(p, "\\u%04x", *q);
} else {
/* UTF-8 continuation bytes (>= 0x80) pass through verbatim. */
*p++ = (char)*q;
}
}
}
p += sprintf(p, "\"}");
return body;
}
freshjots_status freshjots_append(freshjots_client *c,
const char *filename,
const char *text,
long *out_status) {
if (out_status) *out_status = 0;
if (!c || !c->token) return FRESHJOTS_TOKEN_MISSING;
/* 1. URL: encode filename, snprintf into a 1KB stack buffer. */
char *encoded = url_encode(filename);
if (!encoded) return FRESHJOTS_OOM;
char url[1024], auth[256];
int url_n = snprintf(url, sizeof url,
"%s/notes/by-filename/%s/append", BASE_URL, encoded);
free(encoded);
if (url_n < 0 || (size_t)url_n >= sizeof url) return FRESHJOTS_OOM;
/* 2. Authorization header. */
int auth_n = snprintf(auth, sizeof auth,
"Authorization: Bearer %s", c->token);
if (auth_n < 0 || (size_t)auth_n >= sizeof auth) return FRESHJOTS_OOM;
/* 3. JSON body: must live through curl_easy_cleanup — see note below. */
char *body = build_body(text);
if (!body) return FRESHJOTS_OOM;
/* 4. Build curl_slist header list. curl_slist_append returns NULL on OOM;
* don't silently end up with a half-populated list. */
struct curl_slist *headers = curl_slist_append(NULL, auth);
if (!headers) { free(body); return FRESHJOTS_OOM; }
struct curl_slist *next = curl_slist_append(headers, "Content-Type: application/json");
if (!next) { curl_slist_free_all(headers); free(body); return FRESHJOTS_OOM; }
headers = next;
CURL *curl = curl_easy_init();
if (!curl) {
curl_slist_free_all(headers);
free(body);
return FRESHJOTS_NETWORK;
}
curl_easy_setopt(curl, CURLOPT_URL, url);
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, body); /* see note: body must outlive cleanup */
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen(body));
curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L);
curl_easy_setopt(curl, CURLOPT_NOSIGNAL, 1L); /* don't fire SIGALRM on timeout */
long status = 0;
CURLcode rc = curl_easy_perform(curl);
curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &status);
curl_slist_free_all(headers);
curl_easy_cleanup(curl);
free(body); /* safe to free AFTER cleanup, not before */
if (out_status) *out_status = status;
if (rc != CURLE_OK) return FRESHJOTS_NETWORK;
if (status >= 400) return FRESHJOTS_API;
return FRESHJOTS_OK;
}
```
Use it:
```c
#include "freshjots.h"
#include <stdio.h>
int main(void) {
if (freshjots_global_init() != FRESHJOTS_OK) {
fprintf(stderr, "libcurl init failed\n");
return 1;
}
freshjots_client c;
if (freshjots_init(&c, NULL) != FRESHJOTS_OK) {
fprintf(stderr, "FRESHJOTS_TOKEN not set\n");
freshjots_global_cleanup();
return 1;
}
long status;
freshjots_status rc = freshjots_append(
&c, "deploy-log", "deploy ok — sha=abc123", &status);
freshjots_free(&c);
freshjots_global_cleanup();
return rc == FRESHJOTS_OK ? 0 : 2;
}
```
Build:
```sh
cc -std=c99 -Wall -Wextra freshjots.c main.c -lcurl -o app
```
That's the whole dependency surface — libcurl. No JSON library, no string library, no allocator surgery. The hand-rolled JSON escape handles the single string field we ever send (`{"text": "..."}`); the URL encoder follows RFC 3986's unreserved-set rule (alphanumerics and `-_.~` pass through, everything else is `%XX`).
Five design notes worth knowing:
- **Global init is separated from per-client init.** libcurl's `curl_global_init` is documented as not-thread-safe for the *first* call. Splitting it out — caller invokes `freshjots_global_init` once in `main` before any threads — sidesteps that. Per-client `_init`/`_free` only manage the token buffer.
- **Stack buffers for URL (1 KB) and auth header (256 B), heap for the body.** URLs and Bearer tokens have known small bounds (1024 bytes accommodates ~320 chars of post-encoding filename; Fresh Jots tokens are ~42 chars). `snprintf` with overflow checks prevents truncation surprises.
- **`CURLOPT_POSTFIELDS` does NOT copy the body** — libcurl uses the pointer directly through `curl_easy_perform`. The body must outlive `curl_easy_cleanup`. The function's ordering (build body → setopt → perform → cleanup → free body) is load-bearing. If you restructure, use `CURLOPT_COPYPOSTFIELDS` instead — libcurl copies and owns the duplicate.
- **`CURLOPT_NOSIGNAL=1`** prevents libcurl from setting an alarm + signal handler on timeout, which would interfere with apps that have their own SIGALRM handlers (including most CRT-using daemons).
- **`text` must be UTF-8 with no embedded NUL.** The escape function passes bytes ≥ 0x80 through unmodified, which is valid for UTF-8 but produces invalid JSON for other encodings. Convert non-UTF-8 input via `iconv` before calling. Embedded NUL bytes are silently truncated (the API is null-terminated C strings).
2. `libcurl` + `cJSON` — parsed error codes
2. `libcurl` + `cJSON` — parsed error codes
For richer error handling, capture the response body and parse the `{"error": {"code": "..."}}` envelope. cJSON ships as one `.c` + one `.h`; drop both in your tree.
```c
/* Additions / changes to freshjots.c for the cJSON variant */
#include "cJSON.h" /* https://github.com/DaveGamble/cJSON */
/* Accumulate the response body. Tracks length explicitly to avoid O(n^2)
* strlen on every chunk for large responses. */
typedef struct {
char *buf;
size_t len;
size_t cap;
} response_buf;
static size_t write_cb(void *data, size_t size, size_t nmemb, void *userp) {
response_buf *r = (response_buf *)userp;
size_t add = size * nmemb;
if (r->len + add + 1 < r->len) return 0; /* overflow guard */
if (r->len + add + 1 > r->cap) {
size_t ncap = r->cap ? r->cap : 256;
while (ncap < r->len + add + 1) {
if (ncap > SIZE_MAX / 2) return 0;
ncap *= 2;
}
char *p = realloc(r->buf, ncap);
if (!p) return 0;
r->buf = p; r->cap = ncap;
}
memcpy(r->buf + r->len, data, add);
r->len += add;
r->buf[r->len] = '\0';
return add;
}
/* New: returns the parsed error code in *out_code (caller frees, may be NULL). */
freshjots_status freshjots_append_ex(freshjots_client *c,
const char *filename,
const char *text,
long *out_status,
char **out_code) {
/* ... same URL/body/header setup as freshjots_append ... */
response_buf resp = {0};
curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_cb);
curl_easy_setopt(curl, CURLOPT_WRITEDATA, &resp);
CURLcode rc = curl_easy_perform(curl);
/* ... getinfo, cleanup ... */
if (out_code) *out_code = NULL;
if (rc == CURLE_OK && status >= 400 && resp.buf && out_code) {
cJSON *j = cJSON_Parse(resp.buf);
if (j) {
cJSON *err = cJSON_GetObjectItemCaseSensitive(j, "error");
cJSON *code = err ? cJSON_GetObjectItemCaseSensitive(err, "code") : NULL;
if (cJSON_IsString(code) && code->valuestring) {
size_t n = strlen(code->valuestring) + 1;
*out_code = malloc(n);
if (*out_code) memcpy(*out_code, code->valuestring, n);
}
cJSON_Delete(j);
}
}
free(resp.buf);
/* ... return value depends on rc and status as before ... */
}
```
Build:
```sh
cc -std=c99 -Wall freshjots.c cJSON.c main.c -lcurl -o app
```
Use:
```c
long status;
char *code = NULL;
freshjots_status rc = freshjots_append_ex(&c, "deploy-log", entry, &status, &code);
if (rc == FRESHJOTS_API && code && strcmp(code, "rate_limited") == 0) {
sleep(60);
freshjots_append(&c, "deploy-log", entry, NULL); /* retry once */
}
free(code); /* safe on NULL — free(3) accepts it */
```
Reach for this when you want to programmatically branch on the API's `code` field. For systems that just log-and-retry without parsing, the hand-rolled `freshjots_append` is enough.
3. Embedded — vendor HTTPS stack, no libcurl
3. Embedded — vendor HTTPS stack, no libcurl
For MCU / RTOS contexts, libcurl is usually too heavy — it pulls in ~200 KB of code and assumes a full POSIX environment. Use the vendor HTTPS client instead; the wrapper-function pattern is the same:
- **ESP-IDF (ESP32):** `esp_http_client_perform` with `HTTP_METHOD_POST`, `esp_http_client_set_header` for `Authorization`.
- **mbedTLS direct:** open a TLS socket, write a raw HTTP/1.1 request line plus headers and body, read the response line-by-line. ~150 lines, no dependencies beyond mbedTLS.
- **lwIP + mbedTLS:** as above but using lwIP's TCP socket abstraction. Common pattern for Zephyr and RIOT.
The `freshjots_append(token, filename, text)` signature can stay the same — only the body of the function changes to use the vendor transport. Skip the JSON parsing at this layer; you usually only care whether the append succeeded (`return 0` or `return -1`).
Three embedded-specific concerns worth raising:
- **TLS root certificate** — your device must trust the CA that signed freshjots.com's certificate. As of this writing, freshjots.com is served via Let's Encrypt: leaf signed by the `E7` ECDSA intermediate, chaining to `ISRG Root X1` (RSA-4096, valid through June 2035). Shipping ISRG Root X1 (~1.4 KB DER) as a build-time constant is enough today; for forward-compatibility add `ISRG Root X2` (ECDSA P-384, also through 2035), since some future intermediates may chain only there. Verify the current chain before each firmware cut: `openssl s_client -showcerts -servername freshjots.com -connect freshjots.com:443 </dev/null | openssl x509 -noout -issuer`. Pinning the leaf's public key is tempting but breaks every ~60 days when Let's Encrypt auto-renews — pin at the root instead.
- **Battery / power budget** — the radio (Wi-Fi, cellular, LoRa) is usually the dominant energy cost. Batch multiple log lines into one append, or compress before sending. Heartbeats every 15 minutes consume battery; consider longer intervals with deeper deadlines on the Fresh Jots side.
- **Stack vs heap** — embedded toolchains often have small heaps. The hand-rolled body builder above does one `malloc(text_len * 6 + 16)`, which can be 6× the input. For predictable behavior, replace `malloc` with a fixed stack buffer and refuse appends larger than `STACK_BUFFER_SIZE / 6`.
4. Branching on errors
4. Branching on errors
With the `cJSON` variant above, switch on the parsed code:
```c
long status;
char *code = NULL;
freshjots_status rc = freshjots_append_ex(&c, "deploy-log", entry, &status, &code);
if (rc == FRESHJOTS_API && code) {
if (strcmp(code, "rate_limited") == 0) {
sleep(60);
/* caller's retry policy */
} else if (strcmp(code, "cap_exceeded") == 0 ||
strcmp(code, "storage_cap_exceeded") == 0) {
ops_alert(code); /* drop the log entry, page someone */
} else if (strcmp(code, "unauthenticated") == 0 ||
strcmp(code, "forbidden") == 0) {
fprintf(stderr, "token bad — rotate at /settings/api_tokens\n");
exit(2);
}
}
free(code);
```
For systems that only care whether the call succeeded, `rc == FRESHJOTS_OK` is enough — no JSON parsing required, no cJSON dependency.
5. Patterns that work
5. Patterns that work
A. Background heartbeat with `pthread`
```c
#include <pthread.h>
#include <time.h>
#include <unistd.h>
typedef struct {
freshjots_client *fj;
int stop;
pthread_mutex_t mtx;
pthread_cond_t cv;
pthread_t tid;
} heartbeat_t;
static void *heartbeat_run(void *arg) {
heartbeat_t *h = (heartbeat_t *)arg;
pthread_mutex_lock(&h->mtx);
while (!h->stop) {
pthread_mutex_unlock(&h->mtx);
freshjots_append(h->fj, "worker-heartbeat", "alive", NULL);
pthread_mutex_lock(&h->mtx);
struct timespec ts;
clock_gettime(CLOCK_REALTIME, &ts);
ts.tv_sec += 15 * 60; /* 15 minutes */
pthread_cond_timedwait(&h->cv, &h->mtx, &ts);
}
pthread_mutex_unlock(&h->mtx);
return NULL;
}
void heartbeat_start(heartbeat_t *h, freshjots_client *fj) {
h->fj = fj; h->stop = 0;
pthread_mutex_init(&h->mtx, NULL);
pthread_cond_init(&h->cv, NULL);
pthread_create(&h->tid, NULL, heartbeat_run, h);
}
void heartbeat_stop(heartbeat_t *h) {
pthread_mutex_lock(&h->mtx);
h->stop = 1;
pthread_cond_signal(&h->cv);
pthread_mutex_unlock(&h->mtx);
pthread_join(h->tid, NULL);
pthread_mutex_destroy(&h->mtx);
pthread_cond_destroy(&h->cv);
}
```
`pthread_cond_timedwait` wakes both on timeout *and* on signal — `heartbeat_stop` interrupts the 15-minute sleep instead of waiting for it to elapse. Pair with `append_deadline_hours: 1` on the `worker-heartbeat` note in the Fresh Jots UI — when the heartbeat goes silent, you get an email within the hour. See [Everything you can do here](/blog/everything-you-can-do-here).
Two subtleties worth knowing:
- **`CLOCK_REALTIME` can jump.** NTP adjustments, `date -s` from an admin, or a VM resume can move the wall clock forward or backward — and `pthread_cond_timedwait` uses an absolute deadline against that clock. For a 15-minute heartbeat the schedule drift is harmless. For sub-minute intervals or hard latency budgets, use `pthread_condattr_setclock(&attr, CLOCK_MONOTONIC)` + `pthread_cond_init(&cv, &attr)` and `clock_gettime(CLOCK_MONOTONIC, &ts)`. **macOS doesn't support `pthread_condattr_setclock`**, so cross-platform code that needs monotonicity uses `pthread_cond_timedwait_relative_np` on macOS and the attr setter elsewhere.
- **Spurious wakeups.** POSIX permits `pthread_cond_timedwait` to return early without a signal. The worst-case effect here is an extra heartbeat (idempotent on the server side), not a missed shutdown — the `while (!h->stop)` re-check at the top of the loop handles it.
B. Reusing one `CURL*` handle for high-throughput callers
B. Reusing one `CURL*` handle for high-throughput callers
Each call to `freshjots_append` above does a fresh `curl_easy_init` + TLS handshake. For one-off log writes that's fine; for hot loops that push thousands of appends per minute it's wasteful. Two options:
- **Single-threaded reuse:** stash a `CURL*` inside `freshjots_client`, call `curl_easy_reset(curl)` between calls. libcurl's connection pool keeps the TCP/TLS session warm; the second call onward skips the handshake.
- **Multi-threaded reuse:** stash a `CURLSH *` (share handle) inside the client and call `curl_share_setopt(sh, CURLSHOPT_SHARE, CURL_LOCK_DATA_CONNECT)`. Each thread still owns its own `CURL*` but they share the connection cache.
Skip this until profiling shows the handshake cost; for ops logging at human rates, a fresh handle per call is the right default.
C. Async with libcurl's multi handle
C. Async with libcurl's multi handle
For multiple concurrent appends without spinning up threads, libcurl's multi handle drives several easy handles from a single event loop:
```c
CURLM *multi = curl_multi_init();
curl_multi_add_handle(multi, handle1);
curl_multi_add_handle(multi, handle2);
int still_running;
do {
curl_multi_perform(multi, &still_running);
int n;
if (curl_multi_poll(multi, NULL, 0, 1000, &n) != CURLM_OK) break;
} while (still_running);
curl_multi_cleanup(multi);
```
Useful for draining a queue of log writes during shutdown without thread overhead.
D. CMake integration
D. CMake integration
```cmake
find_package(CURL REQUIRED)
add_library(freshjots STATIC freshjots.c)
target_link_libraries(freshjots PUBLIC CURL::libcurl)
target_include_directories(freshjots PUBLIC ${CMAKE_CURRENT_SOURCE_DIR})
# In your consumer:
target_link_libraries(my_app PRIVATE freshjots)
```
E. Make integration
```makefile
CFLAGS += -std=c99 -Wall -Wextra
LDLIBS += -lcurl
my_app: my_app.c freshjots.c
$(CC) $(CFLAGS) $^ -o $@ $(LDLIBS)
```
F. FFI from other languages
The `freshjots` C surface is small enough to wrap from any FFI-capable language — three functions per client (`_init`, `_append`, `_free`) plus the global pair. Use cases:
- **Go:** `cgo` — `// #include "freshjots.h"` then `C.freshjots_init(...)`.
- **Rust:** `bindgen` against `freshjots.h` or hand-written `extern "C"` declarations.
- **Python:** `ctypes` (`CDLL("libfreshjots.so")`) or `cffi`.
- **Lua:** `ffi.cdef` from LuaJIT, or a small `luaopen_`-style binding.
For these languages, prefer the native library where one exists (`pip install freshjots`, the JS/Rust crates, ...) — the C SDK is the fallback when you need to statically link or you're running inside an embedded host that doesn't have a runtime for the higher-level language.
6. Loading the token
6. Loading the token
- **Shell profile** (`~/.bashrc`, `~/.zshrc`) — see Get & set your Fresh Jots API token. `getenv("FRESHJOTS_TOKEN")` reads it. Best for local dev.
- **systemd `EnvironmentFile=`** — for long-running daemons. Pair with `LoadCredential=` to use the systemd credential store on newer systems.
- **`/etc/<app>.conf` + `fopen`** — for sysadmin-managed deployments. `chmod 600` and own it as the service user.
- **Container env / Kubernetes Secret** — for production. Mount as env var, read via `getenv`.
Don't compile the token into the binary (`-DFRESHJOTS_TOKEN=...`). Don't read it from a file world-readable by your service account. `getenv` returning `NULL` is the standard "not set" signal — always check.
7. Going further
7. Going further
- **`libcurl` easy interface:** [curl.se/libcurl/c/libcurl-easy.html](https://curl.se/libcurl/c/libcurl-easy.html)
- **`libcurl` multi interface:** [curl.se/libcurl/c/libcurl-multi.html](https://curl.se/libcurl/c/libcurl-multi.html)
- **`cJSON`:** [github.com/DaveGamble/cJSON](https://github.com/DaveGamble/cJSON)
- **`jansson`** (a heavier, more feature-rich alternative to cJSON): [github.com/akheron/jansson](https://github.com/akheron/jansson)
- **Other languages — same pattern, different HTTP client.** Hub: Write a note from any project.
- **The CLI** — for shell-from-C via `popen`. See Notes from your terminal.
- **Dead-man alerts** — pair the heartbeat thread with `append_deadline_hours`. See Everything you can do here.
One function call, one Authorization header, one note in your account — pasted into a daemon today, alerting you about silent processes tomorrow.