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
- In Kern, open the space the work should go to, then choose Streams… → New stream.
- Pick a starting point. Bug report form sets up summary, description, steps, severity, email, page and browser fields, and maps severity to task priority.
- Under Destination, choose a project and board column. Under Security, add your website's address to Allowed websites.
- 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-Type | How it's read |
|---|---|
application/json | A JSON object. Nested objects are fine (up to 5 levels deep). |
application/x-www-form-urlencoded | Form fields, as a plain <form> sends them. A name like user.email becomes {"user": {"email": …}}; repeated names become lists. |
multipart/form-data | Text fields as above. Attached files are ignored: streams accept text only. |
text/plain | Parsed 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:
| Header | Purpose |
|---|---|
Authorization: Bearer kss_… | The stream secret, for token streams. |
Kern-Signature: t=…,v1=… | The request signature, for signed streams. |
Idempotency-Key | Up to 200 visible ASCII characters. Repeating a key returns the first result instead of creating anything again. Use it when you retry. |
Kern-Turnstile | A Turnstile token, if you don't send it as the cf-turnstile-response field. |
Origin, Referer | Sent 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" }
| Status | Code | Meaning |
|---|---|---|
| 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). |
| 400 | invalid_json, invalid_form, invalid_payload, invalid_idempotency_key | The body or a header couldn't be read. |
| 401 | missing_token, bad_token | Token stream without the right secret. |
| 401 | missing_signature, malformed_signature, stale_signature, bad_signature, replayed_signature | See signed requests. |
| 403 | stream_paused | The stream isn't accepting submissions right now. |
| 403 | origin_not_allowed, origin_required | Sent from a website that isn't allowed, or (public streams) without an Origin header. |
| 403 | ip_not_allowed | The sender's address isn't in the allowlist. |
| 403 | captcha_required, captcha_failed | Turnstile token missing or rejected. |
| 404 | not_found | No stream at this address (or it was deleted). |
| 405 | method_not_allowed | Only POST (and OPTIONS) are accepted. |
| 413 | too_large | Bigger than the stream's size limit. |
| 415 | unsupported_type, forms_disabled | A content type the stream doesn't accept. |
| 422 | invalid_fields | Field validation failed. fields maps each field to its problem. |
| 429 | rate_limited | Too many submissions. Wait for the Retry-After seconds (also in retryAfter). |
| 502 | github_failed, save_failed, … | Accepted but couldn't be delivered. "retryable": true. It's in the log, and admins can redeliver it from Kern. |
| 503 | captcha_unavailable | Turnstile 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.
tmust 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
- Create a Turnstile widget in the Cloudflare dashboard for your site's domain.
- In Security, turn on Turnstile, paste the site key, and save the secret key. It's stored encrypted and never shown again.
- Add the widget to your form. The generated HTML snippet includes it. The widget adds a
cf-turnstile-responsefield; JavaScript senders can pass the token in that field or theKern-Turnstileheader.
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
| Destination | Result |
|---|---|
| Task | A task in the space: in a project and board column if you choose them, otherwise in the Inbox. |
| Inbox item | An inbox item with the title and body, for ideas and feedback you'll sort later. |
| GitHub issue | An issue only. If the repository is connected to a project with issue sync on, Kern imports it as usual. |
| Task + GitHub issue | Both. 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 → Lowandcritical → Urgent. Values0–4work 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
- 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.
- 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. - 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.
| Type | Accepts | Default max length |
|---|---|---|
| Short text | One line. Line breaks become spaces. | 300 |
| Long text | Several lines; shown as a quote in the task. | 5,000 |
| An email address (lower-cased). | 254 | |
| Link | An http or https URL. Other schemes are refused. | 2,000 |
| Number | A number, or text that is one. | n/a |
| Yes / no | true/false, yes/no, on/off, 1/0. | n/a |
| Choice | One 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-responseare 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.
| Placeholder | Inserts |
|---|---|
{{_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:
| Status | Meaning |
|---|---|
| Delivered | Everything was created. |
| Partly delivered | The task was created but not the GitHub issue (or the other way round). The message says why. |
| Failed | Nothing could be created. Fix the cause (often the GitHub token), then Redeliver. |
| Rejected | Field validation failed. |
| Duplicate | A repeat of a recent submission; nothing new was created. |
| Spam | Honeypot, 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
| Setting | Default | Range |
|---|---|---|
| Submissions per sender | 5 a minute, 30 an hour | 1–600 a minute, 1–5,000 an hour |
| Submissions per stream | 200 an hour | up to 1,000 an hour on Kern Cloud |
| Largest submission | 32 KB | 1–256 KB |
| Ignore repeats | 10 minutes | 0–1,440 minutes |
| Signature clock tolerance | 300 seconds | 30–3,600 seconds |
| Log retention | 30 days | 1–90 days |
| Structure | 5 levels deep, 200 fields, 100 items per list, field names up to 128 characters | |
| Per stream | 40 declared fields, 20 allowed websites, 50 IP ranges, 500 blocked senders | |
| Per space | 25 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.