Build a Halo plugin
A plugin is one JSON file with a little JavaScript. It renders right inside the notch, can talk to any API, and — with permission — do system-level things. No build step, no SDK to install.
What is a plugin?
A .haloplugin is a JSON manifest containing metadata and a code string. The code defines a render(ctx) function that returns a small object describing what to draw. Halo renders it natively — your code never touches the UI directly, which keeps plugins safe and fast.
The JavaScript runs in an isolated sandbox (JavaScriptCore). It has no file or network access unless you request it via permissions. The only thing it can reach is the halo host object Halo provides.
The .haloplugin format
{
"id": "com.you.weather",
"name": "Weather",
"version": "1.0.0",
"author": "you",
"icon": "cloud.sun.fill", // any SF Symbol name
"summary": "Local weather at a glance",
"refreshInterval": 600, // seconds (min 15)
"permissions": ["network"], // network | power | notifications
"requirements": [ // shown to users before they enable it
"Needs an internet connection."
],
"code": "function render(ctx){ /* ... */ }"
}render(ctx) & the return shape
The simplest possible plugin — no permissions, static content:
function render(ctx) {
return {
title: "Hello",
subtitle: "My first plugin",
items: [
{ id: "1", text: "It works!", subtitle: "Tapping does nothing yet" }
]
};
}The object you return:
{
title: string // required — card heading
subtitle: string? // optional line under the title
badge: number? // optional count pill
items: [ // optional rows
{
id: string
text: string
subtitle: string?
action: string? // "open:<url>" OR any custom string
}
]
}ctx currently provides { now, plugin } (a timestamp and your plugin id). It grows over time — treat unknown fields defensively.
The halo host API
Everything available to plugin code:
halo.log(msg) // → Console / Halo log
halo.openURL(url) // open a link in the browser
halo.getStorage(key) -> string // persistent per-plugin storage
halo.setStorage(key, value)
// requires "network" permission:
halo.httpSync(JSON.stringify({
url, method?, headers?, body? // body: string or object
})) -> '{"status":200,"body":"..."}'
// requires "power" permission:
halo.power.preventSleep(true|false)
halo.power.isPreventing() -> bool
// requires "notifications" permission:
halo.notify(title, body)
// requires "oauth" permission — guided OAuth 2.0 + PKCE:
halo.oauth.connect(JSON.stringify({
provider, authorizeURL, tokenURL, clientID,
clientSecret?, scopes?, redirectPort // register http://127.0.0.1:<port>/callback
}))
halo.oauth.token(provider) -> string // valid access token (auto-refreshes)
halo.oauth.isConnected(provider) -> boolhalo.oauth lets a plugin connect to Slack / Notion / Google-style services without shipping a secret (PKCE). Register http://127.0.0.1:<redirectPort>/callback in your OAuth app, call connect once from onAction, then read halo.oauth.token(provider) in render and send it as a Bearer header.
function render(ctx) {
if (!halo.oauth.isConnected("notion"))
return { title: "Notion", items: [{ id: "c", text: "Connect Notion", action: "connect" }] };
var token = halo.oauth.token("notion");
// ... use token in halo.httpSync(...) Authorization: "Bearer " + token
return { title: "Notion", subtitle: "Connected" };
}
function onAction(ctx) {
if (ctx.action === "connect") halo.oauth.connect(JSON.stringify({
provider: "notion",
authorizeURL: "https://api.notion.com/v1/oauth/authorize",
tokenURL: "https://api.notion.com/v1/oauth/token",
clientID: ctx.config.clientID, scopes: "", redirectPort: 8899
}));
}httpSync is synchronous on purpose — it runs on the plugin's own background thread, so you write straight-line code. A real network example:
function render(ctx) {
var res = JSON.parse(halo.httpSync(JSON.stringify({
url: "https://api.coinbase.com/v2/exchange-rates?currency=USD"
})));
var rates = JSON.parse(res.body).data.rates;
var btc = "$" + Math.round(1 / parseFloat(rates.BTC)).toLocaleString();
return { title: "Bitcoin", items: [{ id: "btc", text: btc }] };
}Actions & onAction
Give an item an action. If it starts with open:, Halo opens that URL. Any other value calls your onAction(ctx) — re-render happens automatically afterward.
function render(ctx) {
return { title: "Counter",
items: [{ id: "inc", text: "Tap me", action: "increment" }] };
}
function onAction(ctx) {
// ctx.action holds the action string ("increment")
var n = parseInt(halo.getStorage("n") || "0", 10) + 1;
halo.setStorage("n", String(n));
halo.notify("Counter", "Now at " + n); // needs "notifications"
}State & storage
halo.getStorage/setStorage persist strings across refreshes and relaunches. Store JSON with JSON.stringify / JSON.parse. This is how a toggle plugin remembers it's on:
function render(ctx) {
var on = halo.getStorage("on") === "1";
if (on) halo.power.preventSleep(true); // re-assert after relaunch
return { title: "Keep Awake",
subtitle: on ? "Won't sleep" : "Normal",
items: [{ id: "t", text: on ? "Tap to allow sleep" : "Tap to keep awake",
action: "toggle" }] };
}
function onAction(ctx) {
var next = halo.getStorage("on") !== "1";
halo.power.preventSleep(next);
halo.setStorage("on", next ? "1" : "0");
}Permissions & requirements
Request only what you use. Each permission unlocks part of the halo object:
"network" → halo.httpSync
"power" → halo.power.* (prevent sleep)
"notifications" → halo.notifySome plugins need the user to do something first (plug in to power, grant a system permission, paste an API key). List those in requirements — Halo and the marketplace show them before the user enables the plugin, so it works the first time.
API keys, config & authed APIs
Declare a config array in your manifest and Halo renders a settings form automatically (text, password, number). Users fill it in Settings → Plugins; you read the values from ctx.config (or halo.getConfig(key)). Perfect for API keys, a repo name, a city, etc.
// in the manifest:
"config": [
{ "key": "repo", "label": "Repository", "type": "text",
"placeholder": "owner/name", "default": "facebook/react" },
{ "key": "token", "label": "GitHub token", "type": "password",
"help": "Optional — raises the rate limit. Stored locally." }
]function render(ctx) {
var repo = ctx.config.repo || "facebook/react";
var token = ctx.config.token; // user-entered, stored locally
var headers = { "Accept": "application/vnd.github+json", "User-Agent": "Halo" };
if (token) headers["Authorization"] = "Bearer " + token;
var res = JSON.parse(halo.httpSync(JSON.stringify({
url: "https://api.github.com/repos/" + repo, headers: headers
})));
var r = JSON.parse(res.body);
return { title: r.full_name, badge: r.stargazers_count,
items: [{ id: "open", text: r.stargazers_count + " stars",
action: "open:" + r.html_url }] };
}Field types: text, password (masked), number. Values persist locally and are never sent anywhere except in the requests your code makes.
Publishing
- Save your manifest as
my-plugin.haloplugin. - Host it anywhere public (your site, a Gist raw URL, GitHub Pages).
- Users install it in Halo via Settings → Plugins → Install by pasting the URL — or submit it to this marketplace.
- For one-click install, link to
halo://install?url=<your-encoded-plugin-url>— it opens Halo and installs directly (this is what the marketplace's Install in Halo button does).
Tip: develop locally by importing the file (Settings → Plugins → Import). Use halo.log() and watch ~/Library/Logs/Halo/halo.log while you iterate.