Streams

A stream is an address that your website or services send to. Each submission it accepts becomes a task, an inbox item or a GitHub issue in a Kern Cloud space, so a "Report a bug" button, a feedback form or a failing CI job can drop work straight into your plan.

Overview

Every stream has its own address, ending in a 64-character id:

https://streams.relf.cloud/kern/3f9c…64 hexadecimal characters…a1d0
  • Streams belong to a space. That's your personal space or a workspace in Kern Cloud. Whatever a stream creates syncs to everyone in that space within moments, like any other change.
  • Owners and admins manage them. Streams add work from outside, so members and viewers can't see or change them.
  • Settings are enforced by Kern Cloud. Authentication, origins, rate limits, spam checks and validation all happen on the server before anything is created, so they work even when nobody has Kern open.
  • Everything is logged. Each submission that reaches a stream leaves an entry in its delivery log, which you can inspect, redeliver, or use to block a sender.

Open Streams from the space menu at the top of Kern's sidebar (Streams…) or with Ctrl+K → Open streams. You'll need Kern Cloud turned on, with the space open.

Quick start

  1. In Kern, open the space the work should go to, then choose Streams… → New stream.
  2. Pick a starting point. Bug report form sets up summary, description, steps, severity, email, page and browser fields, and maps severity to task priority.
  3. Under Destination, choose a project and board column. Under Security, add your website's address to Allowed websites.
  4. Copy the ready-made snippet from Overview → Send to it into your site. It's generated from your settings. Then use Try it to preview and send a test.

A plain HTML form needs no JavaScript at all:

<form action="https://streams.relf.cloud/kern/your-stream-id" method="POST">
  <input name="title" required maxlength="200" placeholder="What went wrong?">
  <textarea name="description" required></textarea>
  <select name="severity">
    <option>low</option><option>medium</option><option>high</option><option>critical</option>
  </select>
  <!-- Leave empty: bots fill this in and are ignored. -->
  <input name="_hp" tabindex="-1" autocomplete="off" aria-hidden="true" style="position:absolute;left:-9999px">
  <button type="submit">Send</button>
</form>

After a form post, people see a short thank-you page, or go to your own page if you set Responses → After a form post, go to. For a modal that stays on the page, see the bug report modal example.

Sending submissions

Send a POST request to the stream's address. The body can be:

Content-TypeHow it's read
application/jsonA JSON object. Nested objects are fine (up to 5 levels deep).
application/x-www-form-urlencodedForm fields, as a plain <form> sends them. A name like user.email becomes {"user": {"email": …}}; repeated names become lists.
multipart/form-dataText fields as above. Attached files are ignored: streams accept text only.
text/plainParsed as JSON when it is a JSON object (what navigator.sendBeacon sends), otherwise kept as {"message": "…"}.

Form bodies can be turned off with Security → Accept HTML form posts. These request headers have a meaning:

HeaderPurpose
Authorization: Bearer kss_…The stream secret, for token streams.
Kern-Signature: t=…,v1=…The request signature, for signed streams.
Idempotency-KeyUp to 200 visible ASCII characters. Repeating a key returns the first result instead of creating anything again. Use it when you retry.
Kern-TurnstileA Turnstile token, if you don't send it as the cf-turnstile-response field.
Origin, RefererSent by browsers. Origin is checked against allowed websites; the referring page is shown in the task's footer.

Browsers ask permission first (a CORS preflight OPTIONS request) before sending JSON. The stream answers it for the websites you allow, so no extra setup is needed.

Responses and errors

Responses are JSON. A delivered submission answers 201:

{
  "ok": true,
  "id": 1042,                         // delivery log entry
  "status": "delivered",              // or "partial"
  "message": "Thanks! We've received it."
}

Token and signature streams with Responses → Return ids on also get taskId and issue ({"number", "url"}), and any warnings. Public streams never reveal ids. Every response carries a Kern-Event header with the log entry id.

Refusals look like this, with a link back here:

{ "ok": false, "code": "invalid_fields", "message": "Summary is required.",
  "fields": { "title": "Summary is required" }, "docs": "https://kern.chrisrelf.xyz/docs/streams.html" }
StatusCodeMeaning
200(ok)A duplicate: same Idempotency-Key, or identical to a recent submission. "duplicate": true; nothing new was created.
201(ok)Delivered (or partly delivered: "status": "partial", for example the task was created but GitHub refused the issue).
400invalid_json, invalid_form, invalid_payload, invalid_idempotency_keyThe body or a header couldn't be read.
401missing_token, bad_tokenToken stream without the right secret.
401missing_signature, malformed_signature, stale_signature, bad_signature, replayed_signatureSee signed requests.
403stream_pausedThe stream isn't accepting submissions right now.
403origin_not_allowed, origin_requiredSent from a website that isn't allowed, or (public streams) without an Origin header.
403ip_not_allowedThe sender's address isn't in the allowlist.
403captcha_required, captcha_failedTurnstile token missing or rejected.
404not_foundNo stream at this address (or it was deleted).
405method_not_allowedOnly POST (and OPTIONS) are accepted.
413too_largeBigger than the stream's size limit.
415unsupported_type, forms_disabledA content type the stream doesn't accept.
422invalid_fieldsField validation failed. fields maps each field to its problem.
429rate_limitedToo many submissions. Wait for the Retry-After seconds (also in retryAfter).
502github_failed, save_failed, …Accepted but couldn't be delivered. "retryable": true. It's in the log, and admins can redeliver it from Kern.
503captcha_unavailableTurnstile is on but its secret key isn't set.

Spam is answered like success. A filled-in honeypot or a blocked sender gets an ordinary 201, so bots learn nothing. The log records it as spam.

HTML form posts

When a plain form posts (form-encoded, with Accept: text/html), the stream answers with a page instead of JSON. If you set a redirect address, people go there with ?kern=ok, or ?kern=error&code=… when something was wrong.

Authentication

Choose one mode per stream under Security → Authentication.

Public

For forms and modals in web pages. There's no secret, because anything in a web page is readable by its visitors. Protection comes from allowed websites, rate limits, the honeypot, optional Turnstile and validation. Use public streams only for things you'd accept from anyone.

Secret token

For your own servers and scripts. Send the stream secret as a bearer token:

curl -X POST "https://streams.relf.cloud/kern/id" \
  -H "Authorization: Bearer $KERN_STREAM_SECRET" \
  -H "Content-Type: application/json" \
  --data '{"title":"Disk almost full","description":"/var is at 93%"}'

Signed requests

The strongest mode, and the one to use from CI. The secret never travels with the request. Each request carries an HMAC-SHA256 of its body and the current time, so it can't be altered, and it can't be replayed:

Kern-Signature: t=unix seconds,v1=hex HMAC-SHA256(secret, "<t>.<raw body>")
  • Sign the exact bytes you send. Re-serialising JSON changes the bytes and breaks the signature.
  • t must be within the stream's clock tolerance (5 minutes by default, 30 seconds to an hour).
  • Each signature is accepted once. Sign every request afresh, including retries.
  • During a rotation you may include two v1= values, one per secret.

Node.js:

import { createHmac, randomUUID } from "node:crypto";

const body = JSON.stringify({ title: "Nightly build failed", description: "See run 4821" });
const t = Math.floor(Date.now() / 1000);
const v1 = createHmac("sha256", process.env.KERN_STREAM_SECRET).update(`${t}.${body}`).digest("hex");

const res = await fetch("https://streams.relf.cloud/kern/id", {
  method: "POST",
  headers: { "Content-Type": "application/json", "Kern-Signature": `t=${t},v1=${v1}`, "Idempotency-Key": randomUUID() },
  body,
});

Python:

import hashlib, hmac, json, os, time, urllib.request

body = json.dumps({"title": "Nightly build failed", "description": "See run 4821"}).encode()
t = str(int(time.time()))
v1 = hmac.new(os.environ["KERN_STREAM_SECRET"].encode(), t.encode() + b"." + body, hashlib.sha256).hexdigest()
req = urllib.request.Request("https://streams.relf.cloud/kern/id", data=body, method="POST",
    headers={"Content-Type": "application/json", "Kern-Signature": f"t={t},v1={v1}"})
urllib.request.urlopen(req)

Shell (bash and OpenSSL):

BODY='{"title":"Deploy failed","description":"Job 7 on main"}'
T=$(date +%s)
SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$KERN_STREAM_SECRET" -hex | sed 's/^.* //')
curl -X POST "https://streams.relf.cloud/kern/id" \
  -H "Content-Type: application/json" -H "Kern-Signature: t=$T,v1=$SIG" --data "$BODY"

Secrets and rotation

  • Secrets look like kss_ followed by 43 characters (256 random bits). Kern shows one when a stream is created.
  • Rotate issues a new secret. The old one keeps working for a grace period you choose (up to 7 days), so you can update senders without dropping submissions. Stop accepting it ends the grace period early.
  • Owners and admins can reveal the current secret again. Each reveal is recorded in the workspace's activity log.
  • Secrets are stored encrypted (AES-256-GCM). Never put a secret in a web page; that's what public streams are for.

Security and spam

Allowed websites

A list of origins: https://example.com, https://*.example.com (any subdomain, not the bare domain), or http://localhost:3000 while developing. When the list isn't empty, requests whose Origin isn't on it are refused, and browsers on other sites aren't allowed to send at all (CORS). Public streams also refuse requests with no Origin header by default (Only from web pages), which stops simple scripts. Origins can be faked outside browsers, so treat this as a filter, not a lock.

Allowed IP addresses

Addresses or CIDR ranges (203.0.113.0/24, 2001:db8::/32). When set, everything else is refused. This works well for CI runners and servers with fixed addresses.

Honeypot

A hidden field (named _hp unless you change it) that people never see and bots fill in. Submissions with it filled in are dropped quietly. Keep the field in your form, hidden with CSS rather than type="hidden", which bots skip. Clear the name to turn it off.

Cloudflare Turnstile

  1. Create a Turnstile widget in the Cloudflare dashboard for your site's domain.
  2. In Security, turn on Turnstile, paste the site key, and save the secret key. It's stored encrypted and never shown again.
  3. Add the widget to your form. The generated HTML snippet includes it. The widget adds a cf-turnstile-response field; JavaScript senders can pass the token in that field or the Kern-Turnstile header.

Turnstile applies to public streams only.

Blocked senders

Kern Cloud never stores sender IP addresses, only a keyed, one-way hash (16 characters) that identifies a repeat sender. Use Block sender on any log entry. Their future submissions look accepted but are dropped. Unblock them under Security.

Rate limits

Per sender (5 a minute and 30 an hour by default) and for the whole stream (200 an hour). Over the limit, senders get 429 with Retry-After. Requests refused before authentication, such as rate limits, bad origins and bad secrets, aren't logged, so nobody can fill your log from outside.

Repeats and retries

  • Ignore repeats: an identical submission within 10 minutes (adjustable, 0 turns it off) is recorded once and answered as a duplicate. This catches double-clicks.
  • Idempotency-Key: send a unique key per logical submission, and retries with the same key return the original result.

Safe content

  • Escape Markdown (on by default) stops submitted text from adding links, images, HTML or formatting to tasks and issues. Your templates still format normally.
  • Neutralise mentions (on by default) stops submissions from notifying GitHub users or teams (@name) or cross-referencing other issues (#12).
  • Control characters and bidirectional-override characters are removed from all submitted text.
  • Kern never renders raw HTML from task descriptions, and doesn't load remote images.

What submissions become

DestinationResult
TaskA task in the space: in a project and board column if you choose them, otherwise in the Inbox.
Inbox itemAn inbox item with the title and body, for ideas and feedback you'll sort later.
GitHub issueAn issue only. If the repository is connected to a project with issue sync on, Kern imports it as usual.
Task + GitHub issueBoth. When the task's project is connected to the same repository, they're linked and stay in sync both ways.

For tasks you can set:

  • Project and board column: new tasks go to the bottom of the column and take its status. If the column or project is deleted later, tasks fall back to the project or the Inbox, and the log says so.
  • Status (when there's no column) and priority.
  • Priority from a field: map submitted values to priorities, for example low → Low and critical → Urgent. Values 0–4 work without a mapping.
  • Labels from the space, and a due date a number of days after submission (in the workspace's time zone).

GitHub issues

  1. Create a fine-grained personal access token. Choose Only select repositories with just the target repository, grant Issues: Read and write and nothing else, and set an expiry.
  2. In the stream's GitHub section, enter the repository as owner/name, save, then paste the token. Kern Cloud checks the token can reach the repository before storing it.
  3. Choose GitHub issue or Task + GitHub issue under Destination.

The token is encrypted at rest (AES-256-GCM), used only to open issues for this stream, and can't be read back by anyone. Remove it any time, and revoke it on GitHub if you stop using the stream. Optional issue labels and assignees are sent along; if GitHub refuses them, the issue is opened without them and the log notes it.

With Link to the task on and the task's project connected to the same repository (Project → Code), the task and issue start linked, exactly as if you'd linked them in Kern. Title, description, state and labels then sync both ways, and Kern won't import the issue a second time.

Fields and validation

Declare the fields a stream expects. Each has a key (dots for nesting, like user.email), a label, a type, and optional rules. Submissions that fail are refused with 422 and listed in the log as rejected; nothing is created.

TypeAcceptsDefault max length
Short textOne line. Line breaks become spaces.300
Long textSeveral lines; shown as a quote in the task.5,000
EmailAn email address (lower-cased).254
LinkAn http or https URL. Other schemes are refused.2,000
NumberA number, or text that is one.n/a
Yes / notrue/false, yes/no, on/off, 1/0.n/a
ChoiceOne of the listed options (case-insensitive).n/a
  • Required fields must be present and not blank. Optional blank fields are dropped.
  • Hidden fields are validated and usable in templates, but left out of {{_fields}}.
  • Other fields: Keep accepts undeclared fields and lists them after the declared ones; Reject refuses submissions that contain them.
  • The honeypot field and cf-turnstile-response are removed before validation.

Templates

The title and body are templates. {{key}} inserts a submitted value ({{user.email}} for nested ones). The body is Markdown. If the title renders empty, the fallback title is used.

PlaceholderInserts
{{_fields}}Every submitted field as labelled Markdown, declared fields first.
{{_json}}The whole submission as a JSON code block.
{{_stream.name}}The stream's name.
{{_meta.receivedAt}}When it arrived (ISO 8601, UTC).
{{_meta.origin}}, {{_meta.referer}}The sending website and page.
{{_meta.userAgent}}The sender's browser or client.
{{_meta.sender}}The sender's anonymised id.

Filters follow a bar: {{severity | upper}}, lower, trim, json, and {{name | default:"Anonymous"}}. For example:

Title:  [{{severity | upper | default:"BUG"}}] {{title}}
Body:   Reported by {{email | default:"someone anonymous"}} on {{page}}

        {{_fields}}

A footer with where and when the submission arrived is added by default (Add a footer), and the raw submission can be attached too. Titles are cut at 200 characters and bodies at 60,000.

Delivery log

Everything that passed authentication is logged with one of these outcomes:

StatusMeaning
DeliveredEverything was created.
Partly deliveredThe task was created but not the GitHub issue (or the other way round). The message says why.
FailedNothing could be created. Fix the cause (often the GitHub token), then Redeliver.
RejectedField validation failed.
DuplicateA repeat of a recent submission; nothing new was created.
SpamHoneypot, failed Turnstile, or a blocked sender. Spam's data is never kept.
  • Open an entry to see the submitted data, the website, the anonymised sender, the client and how long delivery took.
  • Redeliver runs a logged submission through the stream again, with its current settings.
  • Entries are kept 30 days by default (1 to 90). Turn off Keep submitted data to keep only outcomes; you then can't redeliver.
  • Notifications: people with the space open see each delivery in Kern (each person can turn this off in Settings → Cloud). Email can go to the stream's creator or all admins for every submission or only problems, at most one a minute per stream.

Limits

SettingDefaultRange
Submissions per sender5 a minute, 30 an hour1–600 a minute, 1–5,000 an hour
Submissions per stream200 an hourup to 1,000 an hour on Kern Cloud
Largest submission32 KB1–256 KB
Ignore repeats10 minutes0–1,440 minutes
Signature clock tolerance300 seconds30–3,600 seconds
Log retention30 days1–90 days
Structure5 levels deep, 200 fields, 100 items per list, field names up to 128 characters
Per stream40 declared fields, 20 allowed websites, 50 IP ranges, 500 blocked senders
Per space25 streams

Examples

A bug report modal

Using the Bug report form starting point, with your site in Allowed websites:

<dialog id="bug">
  <form id="bug-form">
    <h2>Report a bug</h2>
    <input name="title" required maxlength="200" placeholder="Summary">
    <textarea name="description" required placeholder="What happened?"></textarea>
    <textarea name="steps" placeholder="Steps to reproduce (optional)"></textarea>
    <select name="severity"><option>low</option><option selected>medium</option><option>high</option><option>critical</option></select>
    <input name="email" type="email" placeholder="Email, if we may follow up">
    <input name="_hp" tabindex="-1" autocomplete="off" aria-hidden="true" style="position:absolute;left:-9999px">
    <p id="bug-status" role="status"></p>
    <button>Send</button>
  </form>
</dialog>

<script>
  const STREAM = "https://streams.relf.cloud/kern/your-stream-id";
  const form = document.getElementById("bug-form");
  const status = document.getElementById("bug-status");

  form.addEventListener("submit", async (event) => {
    event.preventDefault();
    const data = Object.fromEntries(new FormData(form));
    status.textContent = "Sending…";
    try {
      const res = await fetch(STREAM, {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ ...data, page: location.href, browser: navigator.userAgent }),
      });
      const out = await res.json();
      if (!out.ok) {
        status.textContent = res.status === 429 ? `Please wait ${out.retryAfter} seconds and try again.` : out.message;
        return;
      }
      status.textContent = out.message;
      form.reset();
      setTimeout(() => document.getElementById("bug").close(), 1500);
    } catch {
      status.textContent = "Couldn't send. Check your connection and try again.";
    }
  });
</script>

GitHub Actions: open an issue when a workflow fails

A signed stream set to Task + GitHub issue, with the secret saved as the repository secret KERN_STREAM_SECRET:

- name: Report failure to Kern
  if: failure()
  env:
    KERN_STREAM_SECRET: ${{ secrets.KERN_STREAM_SECRET }}
    KERN_STREAM_URL: https://streams.relf.cloud/kern/your-stream-id
  run: |
    BODY=$(jq -nc --arg t "CI failed on ${GITHUB_REF_NAME}" \
      --arg b "Run: ${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" \
      '{title: $t, body: $b}')
    T=$(date +%s)
    SIG=$(printf '%s.%s' "$T" "$BODY" | openssl dgst -sha256 -hmac "$KERN_STREAM_SECRET" -hex | sed 's/^.* //')
    curl -fsS -X POST "$KERN_STREAM_URL" \
      -H "Content-Type: application/json" \
      -H "Kern-Signature: t=$T,v1=$SIG" \
      -H "Idempotency-Key: ${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" \
      --data "$BODY"

Forward from your own backend

If you'd rather not expose the stream to browsers at all, post from your server with a token or signature. Your server can add details only it knows, such as the signed-in user's id.

// e.g. an Express route behind your own authentication
app.post("/api/feedback", async (req, res) => {
  const r = await fetch(process.env.KERN_STREAM_URL, {
    method: "POST",
    headers: { "Content-Type": "application/json", Authorization: `Bearer ${process.env.KERN_STREAM_SECRET}` },
    body: JSON.stringify({ title: req.body.title, message: req.body.message, account: req.user.id }),
  });
  res.status(r.ok ? 204 : 502).end();
});

Troubleshooting

The browser says the request was blocked by CORS

Add the page's exact origin (scheme, host and port, such as https://www.example.com) to Allowed websites, and save. example.com and www.example.com are different origins; https://*.example.com covers subdomains.

origin_required when testing with cURL

Public streams expect requests from web pages. Add -H "Origin: https://your-site" while testing, or switch to a token or signed stream for server-side senders.

bad_signature though the secret is right

Sign the exact bytes you send, with no re-serialising or added whitespace, as <t>.<body> with a literal dot. Check the secret has no trailing newline (printf, not echo), and that you're using the current secret after a rotation.

Submissions are "Partly delivered"

The task was created but GitHub refused the issue. The log message says why: usually an expired token or one without Issues: read and write. Fix it under GitHub (use Check), then Redeliver. Redelivering creates a new task, so delete the earlier one if you don't want both.

Nothing appears in Kern

Check the delivery log first. If the entry is there, make sure you have the stream's space open: streams add to their own space, not to offline data or another workspace. If there's no entry, the request was refused before authentication, so look at the response's code.

Is the stream id a secret?

It's unguessable, but anyone who can see your page can read a public stream's address, so treat it as public. Rely on the protections above, and pause the stream or create a new one if it's abused.

Something missing or unclear? Open an issue. See the privacy policy for what streams store.