Integration guide

From installing the app to your first alert landing on a teammate's phone: about five minutes.

Four steps The push URL Request & response In your language Four things that bite Example: CI Example: AI employees Invite the team

1 · Four steps, start to finish

1

Install, then create a project

Sign in with a six-digit email code — no password to invent. A project is the unit that alerts and members belong to.

2

Generate a push key

Project Settings → Push Key → Generate → Copy. What you get is a complete URL, not a bare token.

3

POST to it

One HTTP request from a terminal, a CI job, a cron entry or a running agent. A title and a Markdown body.

4

Invite the team

Project Settings → Invite Members. They scan the QR code and join; from then on every push reaches all of them.

2 · The push URL

One URL per project. It carries the project ID in the path and the key in the query string, so it works anywhere a URL works — no SDK, no headers to negotiate.

You do not need the app to create a project or generate that key: open the DevOmni web console in any browser, sign in by scanning the QR code with the app, and create the project there. It is also where you import API docs.

https://x.icloser.xyz/open/projects/<PROJECT_ID>/messages?key=<128-CHARACTER KEY>

The key is a bearer credential: anyone holding this URL can post into your project. Treat it exactly like a password — see §5 for where to keep it.

3 · Request and response

A single POST with a JSON body. Nothing else is required.

curl -X POST "https://x.icloser.xyz/open/projects/<PROJECT_ID>/messages?key=<KEY>" \ -H 'Content-Type: application/json' \ -d '{"title":"Payment service degraded","content":"# Payment callback timing out\n\n**Impact**: orders succeed but no callback arrives"}'

The response looks like this:

{"code":0,"data":{"messageId":372475276749180928,"memberCount":2,"pushCount":2},"message":"success"}

What you send

FieldRequiredNotes
titleyesPlain text, up to 200 bytes. This is the push notification's headline, so keep it short and specific.
contentyesMarkdown, up to 64 KB. Headings, bold, tables, code fences, links and - [ ] checklists all render.
formatneverLegacy compatibility field. Sending it flattens your Markdown — see §5.1.

What you get back

FieldMeaning
code0 means accepted. Anything else is a failure and message says why.
data.messageIdThe message's permanent ID. Keep it if you want to correlate with your own records.
data.memberCountHow many project members the message was delivered to.
data.pushCountHow many devices actually received a push notification.

pushCount lower than memberCount is normal, not an error: some members have no registered device yet, or have declined notification permission. The message is stored either way and they will see it the next time they open the app — or immediately, if you have an email or bot channel configured.

4 · In your language

All five send the same request. Pick one, paste your push URL in, run it — you should get a notification before you finish reading this sentence.

# Read the URL from the environment; never paste it into a script you commit. export DEVOMNI_PUSH_URL='https://x.icloser.xyz/open/projects/<PROJECT_ID>/messages?key=<KEY>' curl -sS -X POST "$DEVOMNI_PUSH_URL" \ -H 'Content-Type: application/json' \ -d '{"title":"Payment service degraded","content":"# Payment callback timing out\n\n**Impact**: orders succeed but no callback arrives"}'
# Standard library only — nothing to install. import json, os, urllib.request URL = os.environ["DEVOMNI_PUSH_URL"] payload = { "title": "Payment service degraded", "content": "# Payment callback timing out\n\n**Impact**: orders succeed but no callback arrives", } req = urllib.request.Request( URL, data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, ) with urllib.request.urlopen(req, timeout=10) as resp: print(resp.read().decode("utf-8"))
// Java 11+, JDK HttpClient — no third-party HTTP library. import java.net.URI; import java.net.http.HttpClient; import java.net.http.HttpRequest; import java.net.http.HttpResponse; import java.nio.charset.StandardCharsets; public class DevOmniPush { public static void main(String[] args) throws Exception { String url = System.getenv("DEVOMNI_PUSH_URL"); String body = "{\"title\":\"Payment service degraded\"," + "\"content\":\"# Payment callback timing out\\n\\n" + "**Impact**: orders succeed but no callback arrives\"}"; HttpRequest req = HttpRequest.newBuilder(URI.create(url)) .header("Content-Type", "application/json") .POST(HttpRequest.BodyPublishers.ofString(body, StandardCharsets.UTF_8)) .build(); HttpResponse<String> res = HttpClient.newHttpClient() .send(req, HttpResponse.BodyHandlers.ofString()); System.out.println(res.body()); } }
<?php // PHP 7.4+ with ext-curl. $url = getenv('DEVOMNI_PUSH_URL'); $payload = json_encode([ 'title' => 'Payment service degraded', 'content' => "# Payment callback timing out\n\n**Impact**: orders succeed but no callback arrives", ], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES); $ch = curl_init($url); curl_setopt_array($ch, [ CURLOPT_POST => true, CURLOPT_POSTFIELDS => $payload, CURLOPT_HTTPHEADER => ['Content-Type: application/json'], CURLOPT_RETURNTRANSFER => true, CURLOPT_TIMEOUT => 10, ]); echo curl_exec($ch); curl_close($ch);
// Node 18+ — global fetch, no dependencies. const res = await fetch(process.env.DEVOMNI_PUSH_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: 'Payment service degraded', content: '# Payment callback timing out\n\n**Impact**: orders succeed but no callback arrives', }), }); console.log(await res.json());

5 · Four things that bite

5.1 · Never send a format field

This is the one that costs people an afternoon. format exists only so that very old clients keep working. Send "format":"markdown" and the server flattens your body into plain text before it ever reaches a phone — headings become bracketed labels, bold is stripped, and tables collapse into runs of separators. Omit the field and the same body renders properly.

What you sendWhat the phone shows
No format field (do this)# Heading renders as a heading, **bold** as bold, | A | B | as a real table
"format":"markdown"【Heading】, the bold gone, the table reduced to · 1:2 — text only

There is no case in which a new integration should send this field. Just leave it out.

5.2 · Size limits

title is capped at 200 bytes and content at 64 KB. Both are counted in bytes, not characters, so a CJK title runs out roughly three times faster than an ASCII one. If you are forwarding a log, send the last few hundred lines and a link to the rest — a 64 KB wall of text is not something anyone reads on a phone anyway.

5.3 · Keep the key out of your repository

Anyone with the URL can post to the project, so it belongs in a CI secret or an environment variable — never in a committed file, and never in a client-side bundle. If it does leak, open Project Settings → Push Key → Rotate: the old URL stops working immediately, with no window in between. Revoke does the same thing permanently.

5.4 · People without the app still get alerted

Backend engineers, ops and managers should not have to install anything to hear about an outage. Project Settings → Alert Channels routes the same messages to email, a Feishu bot or a DingTalk bot. Each channel has a test button, so you can confirm it works the moment you configure it.

6 · Example: a build failure from GitHub Actions

Store the push URL as the repository secret DEVOMNI_PUSH_URL, then add one step guarded by if: failure(). Building the JSON with jq rather than string concatenation means a branch name with a quote in it cannot break the request. jq is preinstalled on GitHub-hosted runners.

name: build on: [push] jobs: build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - run: make build - name: Notify DevOmni on failure if: failure() env: PUSH_URL: ${{ secrets.DEVOMNI_PUSH_URL }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} run: | TITLE="Build failed: ${{ github.repository }}" CONTENT=$(printf '## %s #%s\n\n| | |\n|---|---|\n| Branch | `%s` |\n| Commit | `%s` |\n| By | @%s |\n\n[Open the run log](%s)' \ "${{ github.workflow }}" "${{ github.run_number }}" \ "${{ github.ref_name }}" "${GITHUB_SHA:0:7}" "${{ github.actor }}" "$RUN_URL") jq -n --arg t "$TITLE" --arg c "$CONTENT" '{title:$t,content:$c}' \ | curl -sS -X POST "$PUSH_URL" -H 'Content-Type: application/json' -d @-

The result on your phone is a heading, a small table of branch / commit / author, and a tappable link straight to the failing run. The same pattern drops into GitLab CI, Jenkins or a plain trap in a deploy script — nothing about it is GitHub-specific.

7 · Example: AI employees reporting in

An AI employee's defining property is that it works while you are not at the keyboard. That is exactly what makes a phone the right place for its output. Three moments are worth a push, and only three:

Without those three reaching you, you are back to watching a screen — which rather defeats the point of hiring one. The integration below is the ordinary push URL, so it works with any agent framework, any orchestration script, any cron-driven automation. We use it with KernelHub, where the Agent decides, a runtime such as Claude, Codex or Qwen does the thinking, and an Executor on your own Mac or Windows machine performs only actions you have authorised — but nothing here depends on it.

These snippets use requests (pip install requests). If you would rather not add a dependency, the standard-library version in §4 sends the identical request.

7.1 · It finished

Lead with the deliverable, not with "task complete". A table for the numbers, a link to the diff, and a checkbox you can tick off later.

import os, requests PUSH_URL = os.environ["DEVOMNI_PUSH_URL"] def notify_done(task, files_changed, diff_url, duration): requests.post(PUSH_URL, timeout=10, json={ "title": f"Agent finished: {task}", "content": f"""## {task} | | | |---|---| | Files changed | {files_changed} | | Took | {duration} | [Review the diff]({diff_url}) - [ ] Reviewed by me """, })

7.2 · It is stuck, or it hit a boundary

Say what it wanted to do and which rule stopped it. A message that only says "failed" makes you open a laptop; this one usually does not.

def notify_blocked(task, action, rule): requests.post(PUSH_URL, timeout=10, json={ "title": f"⚠ Agent blocked: {task}", "content": f"""**It wanted to run** `{action}` **Stopped by**: {rule} Nothing was executed. Either widen the grant or send it back with different instructions — your call. """, })

7.3 · It needs your approval

Everything needed for the decision goes in the body: the exact action, the blast radius, whether it can be undone. If you have to open a laptop to decide, the notification has failed.

def notify_approval(action, blast_radius, reversible, approve_url): requests.post(PUSH_URL, timeout=10, json={ "title": f"Approval needed: {action}", "content": f"""## {action} | | | |---|---| | Blast radius | {blast_radius} | | Reversible | {reversible} | [Approve]({approve_url}/yes) · [Decline]({approve_url}/no) - [ ] Decided """, })

One thing that fits this workflow particularly well: what an agent pushes is not write-once. Open the report on your phone, edit the Markdown in place — add the conclusion you reached, tick off the checklist, delete the noise — and optionally notify the team again. You get one document that improves, not eleven near-identical copies in a list. Export it as .md when the work is done.

8 · Invite the team

Project Settings → Invite Members produces a QR code and a link. A teammate scans it with the app and is in the project; from that moment every push reaches their phone too, with no further configuration on your side. memberCount in the response goes up by one, which is the quickest way to confirm it worked.

For anyone who will not install the app, use Alert Channels instead (§5.4). And if you would rather work from a desktop, open the DevOmni web console (x.icloser.xyz/console) and scan the QR code with the app: the confirmation screen shows that machine's IP and browser, the code expires in two minutes, and it works exactly once. The console is also where you check endpoints and import an OpenAPI, Swagger or Postman document.

Next

That is the whole surface area. One URL, one POST, no SDK to keep up to date. If something does not behave the way this page describes, tell us — support reaches a person.

Download on the App Store Android Open the console ↗ See all features