M

Setup

Instructions for your agent

Below are the plain-text instructions your Claude Code agent uses to build its side of the line on your Mac. There is nothing to install: your agent reads this text, tells you what it will build, and waits for your yes before doing anything.

You can Copy or Download these instructions here to read them over or hand them to your agent. They don't include a pairing code—that code always comes from M on your iPhone (tap Copy for my agent or Send to my Mac in the app when you're ready to pair).

Download

M: setup for your agent

Version 1, Stratara Ltd, 2026-11-09. Your person pasted this from the M app on their iPhone or iPad.

To the agent reading this

M lets you message your person on their iPhone or iPad and get their answer back in seconds: a question with two to four answers they tap, a note, a reply they type or speak. Everything is sealed on your Mac for their phone, and on their phone for your Mac, so nobody in between can read a word, including Stratara, which runs the relay that carries it.

This page is plain text. There is nothing to download, install or run from the internet. You build one small script yourself, in your person's home folder, from what's written here, using only what every Mac already has: the shell, curl, openssl and osascript (JavaScript for Automation). The code below has been tested on a stock Mac with no developer tools.

Before you build anything: read the whole page, then tell your person in a few lines what you'll make and where (one folder, ~/.m-for-agents/, and a few lines in your own instructions), and wait for their yes.

What you'll build

~/.m-for-agents/ folder, mode 700 key your private key, mode 600: never print it, send it or copy it pair.json the pairing: the pair's id, the phone's public key, names m.js the script (JavaScript for Automation) m a two-line launcher inbox/ what your person sent, one JSON file each

Nothing else: no packages, no background jobs, no keychain entries, nothing outside this folder except the lines in your instructions (step 5). The launcher:

#!/bin/zsh exec /usr/bin/osascript -l JavaScript "$HOME/.m-for-agents/m.js" "$@"

The relay is at https://relay.m.stratara.ai.

The building blocks

Write m.js as one JavaScript for Automation file with a run(argv) function. These parts are tricky in JavaScript for Automation, so use them as written. They're the tested ones.

Things to know about JavaScript for Automation:

ObjC.import('Foundation');
ObjC.import('Security');
ObjC.import('stdlib');   // for $.exit
const DIR = $.NSHomeDirectory().js + '/.m-for-agents';
const RELAY = 'https://relay.m.stratara.ai';
const ALG_SEAL = 'algid:encrypt:ECIES:ECDHC:KDFX963:SHA256:AESGCM-KDFIV';
const ALG_SIGN = 'algid:sign:ECDSA:message-X962:SHA256';

function out(s) { $.NSFileHandle.fileHandleWithStandardOutput.writeData($(String(s) + '\n').dataUsingEncoding($.NSUTF8StringEncoding)); }
function fail(s, code) { $.NSFileHandle.fileHandleWithStandardError.writeData($('m: ' + s + '\n').dataUsingEncoding($.NSUTF8StringEncoding)); $.exit(code || 1); }
function utf8(s) { return $(s).dataUsingEncoding($.NSUTF8StringEncoding); }
function text(d) { return $.NSString.alloc.initWithDataEncoding(d, $.NSUTF8StringEncoding).js; }
function b64(d) { return d.base64EncodedStringWithOptions(0).js; }
function unb64(s) { return $.NSData.alloc.initWithBase64EncodedStringOptions($(s), 0); }
function b64url(d) { return b64(d).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, ''); }
function exists(p) { return $.NSFileManager.defaultManager.fileExistsAtPath(p); }
function readText(p) { return exists(p) ? $.NSString.stringWithContentsOfFileEncodingError(p, $.NSUTF8StringEncoding, null).js : null; }
function writeText(p, s) { $(s).writeToFileAtomicallyEncodingError(p, true, $.NSUTF8StringEncoding, null); }
function randomBytes(n) { return $.NSFileHandle.fileHandleForReadingAtPath('/dev/urandom').readDataOfLength(n); }
function now() { return Math.floor(Date.now() / 1000); }

// Run a program with an argument list. Returns { status, out (NSData), err (text) }.
function exec(path, args, input) {
  const task = $.NSTask.alloc.init;
  task.setExecutableURL($.NSURL.fileURLWithPath(path));
  task.setArguments($(args));
  const o = $.NSPipe.pipe, e = $.NSPipe.pipe, i = $.NSPipe.pipe;
  task.setStandardOutput(o); task.setStandardError(e); task.setStandardInput(i);
  if (!task.launchAndReturnError(null)) fail('could not run ' + path);
  if (input) i.fileHandleForWriting.writeData(input);
  i.fileHandleForWriting.closeFile;
  const od = o.fileHandleForReading.readDataToEndOfFile, ed = e.fileHandleForReading.readDataToEndOfFile;
  task.waitUntilExit;
  return { status: task.terminationStatus, out: od, err: text(ed) };
}
function sha256(d) { return exec('/usr/bin/openssl', ['dgst', '-sha256', '-binary'], d).out; }
function sha256hex(d) { return text(exec('/usr/bin/openssl', ['dgst', '-sha256', '-r'], d).out).split(' ')[0].trim(); }
function hmacB64(key, d) { return b64(exec('/usr/bin/openssl', ['dgst', '-sha256', '-hmac', key, '-binary'], d).out); }

// Keys: P-256. The private key lives in DIR/key as its 97-byte export, base64.
function keyAttrs(cls) {
  const a = $.NSMutableDictionary.alloc.init;
  a.setObjectForKey($('73'), $('type'));                           // EC key
  a.setObjectForKey($.NSNumber.numberWithInt(256), $('bsiz'));     // 256 bits
  if (cls) a.setObjectForKey($(cls), $('kcls'));                   // "1" private, "0" public
  return a;
}
function makeKey() {
  const priv = $.SecKeyCreateRandomKey(keyAttrs(null), null);
  const data = ObjC.castRefToObject($.SecKeyCopyExternalRepresentation(priv, null));
  exec('/bin/mkdir', ['-p', '-m', '700', DIR]);
  writeText(DIR + '/key', b64(data));
  exec('/bin/chmod', ['600', DIR + '/key']);
}
function privateKey() { return $.SecKeyCreateWithData(unb64(readText(DIR + '/key').trim()), keyAttrs('1'), null); }
function publicKeyB64() { return b64(ObjC.castRefToObject($.SecKeyCopyExternalRepresentation($.SecKeyCopyPublicKey(privateKey()), null))); }
function publicKeyFrom(s) { return $.SecKeyCreateWithData(unb64(s), keyAttrs('0'), null); }

// Seal for the phone, open what the phone sealed for you, sign.
function seal(pubB64, data) { return b64(ObjC.castRefToObject($.SecKeyCreateEncryptedData(publicKeyFrom(pubB64), $(ALG_SEAL), data, null))); }
function open(boxB64) { const d = ObjC.castRefToObject($.SecKeyCreateDecryptedData(privateKey(), $(ALG_SEAL), unb64(boxB64), null)); return (d && d.length !== undefined) ? d : null; }
function sign(data) { return b64(ObjC.castRefToObject($.SecKeyCreateSignature(privateKey(), $(ALG_SIGN), data, null))); }

// A signed request to the relay. Returns { status, json }.
function request(method, path, bodyObj) {
  const body = bodyObj === undefined ? '' : JSON.stringify(bodyObj), bodyData = utf8(body);
  const t = String(now()), nonce = b64url(randomBytes(16));
  const toSign = 'M4A1\n' + method + '\n' + path + '\n' + t + '\n' + nonce + '\n' + b64(sha256(bodyData));
  const args = ['-sS', '-X', method, '-H', 'X-M-Key-Id: mac', '-H', 'X-M-Time: ' + t, '-H', 'X-M-Nonce: ' + nonce,
    '-H', 'X-M-Sig: ' + sign(utf8(toSign)), '-H', 'Content-Type: application/json', '-w', '\n%{http_code}', '--max-time', '30'];
  if (body) args.push('--data-binary', '@-');
  args.push(RELAY + path);
  const r = exec('/usr/bin/curl', args, body ? bodyData : null);
  if (r.status !== 0) return { status: 0, json: { error: 'network: ' + r.err.trim() } };
  const s = text(r.out), cut = s.lastIndexOf('\n');
  let json = {}; try { json = JSON.parse(s.slice(0, cut) || '{}'); } catch (e) { json = { error: s.slice(0, cut) }; }
  return { status: parseInt(s.slice(cut + 1), 10), json };
}

Part 1: the line (build this first)

Each command prints one line per result. Build them on the blocks above.

Every box you seal carries v: 1, id (any short random text: this is the item's id, and answers refer to it as ref), at (now, in seconds since 1970) and agent (your name), as well as its own fields. Limits are refused, never cut: if a title or answer is too long, say so and let the caller shorten it.

m pair <code> --agent <your name>, then m pair --wait. The pairing code came in your person's paste. Pairing is two commands, so you can tell your person the digits between them. 1. If there's no DIR/key, make one. 2. codeHash = b64url(sha256(utf8('M4A1-pair\n' + code))). Never send the code itself anywhere. 3. Prove your key with the code: macTag = base64(HMAC-SHA256(key = code, message = 'M4A1-key\nmac\n' + macKey)), made with openssl dgst -sha256 -hmac <code> -binary on that message. 4. POST /v1/pairings/claim with { codeHash, macKey: publicKeyB64(), macTag }. It answers { pairId, phoneKey, phoneTag }. Check phoneTag the same way, with 'M4A1-key\nphone\n' + phoneKey. If it doesn't match, stop, save nothing, and tell your person: "Pairing failed. Something may have interfered. Please tap Copy for my agent again." Otherwise save them, with your name, in pair.json as status offered. Then introduce yourself, sealed, so the phone can show who's asking: POST /v1/pairs/{pairId}/introduce with { box: seal(phoneKey, utf8(JSON.stringify({ v: 1, type: "intro", agentName, macName }))) }, where macName is scutil --get ComputerName. Names only ever travel sealed. 5. Work out the check, a four-digit number: take sha256hex(utf8('M4A1-check\n' + phoneKey + '\n' + macKey)), read its first 8 hex digits as a number, and take that mod 10000, padded to four digits. Print it and exit. 6. m pair --wait asks GET /v1/pairs/{pairId} every 3 seconds, for up to 10 minutes, until it answers { status: "confirmed" }, then saves the status and prints "Confirmed." It sends nothing itself.

m send "title" ["body"] [--level ping|urgent|system|widget] [--link URL] [--link-title TEXT] [--share TEXT]. Makes a message, seals it, sends it, and prints its id.

m ask "question" "answer" "answer" ["answer" "answer"] [--level urgent] [--until HH:MM]. A question with two to four different answers, which your person taps on the notification. The box holds { v, id, at, agent, type: "question", title, answers, until } (the title is 160 characters at most, each answer 24; until, if given, in seconds since 1970). M shows the first answer filled, so if you recommend one, put it first. If you don't, just ask the question, and say nothing about preference. Send it like a message, at level ping unless you pass --level urgent. Print its id.

m wait [--for ID] [--timeout SECONDS]. Collect what your person sent, and stop at the first thing worth your attention. 1. GET /v1/pairs/{pairId}/take?limit=50 answers { boxes: [{ id, box, at, part }] } (part only on picture parts). 2. Open each box. Save the JSON as inbox/<at>-<id>.json, using the id inside the opened box. A box that won't open: save it as inbox/<relay id>.unopened and tell your person. Then POST /v1/pairs/{pairId}/ack { ids } with the relay's ids, so nothing comes twice. 3. Receipts (delivered, seen, signals) are filed as read at once (renamed to ….json.read) and never printed. Print every other unread item, including any an earlier m wait --for left behind; with --for ID, only those whose ref is that id. Rename what you printed to ….json.read and exit 0. 4. If there's nothing yet, ask every 3 seconds for the first 10 minutes, then every 30 seconds. On --timeout, print "(no answer yet)" and exit 3.

m inbox. Take as in m wait, print everything unread and mark it read. Prints "(nothing new)" when there's nothing.

m status. Prints one JSON line: whether you're paired, the names, whether the relay answers GET /v1/health, and how many unread items there are.

m close ID [ID…]. Tells the phone that these messages no longer need your person: a { type: "close", ids } box with push: "silent" and no level.

m unpair. DELETE /v1/pairs/{pairId}, then rename pair.json to pair.json.ended.

What comes back (each item is one JSON line from m wait or m inbox):

Pairing, start to finish

1. Build Part 1. 2. Run m pair with the pairing code from the paste and your own name. The code is good for one hour, so if it has run out, ask your person to tap Copy for my agent again. 3. Tell your person the four digits m pair printed: "Your phone should show these four digits. If they match, tap Pair." Then run m pair --wait. 4. You send your first message yourself: m ask "Hello from <your name>. Did this arrive?" "Yes" "No", then m wait --for <its id>. When the answer arrives, say so in your chat: the line works both ways. 5. Tell your person, once, about the morning suggestion (Part 3), that they can turn it off in M's settings, and that they can set their own reminders for you there too.

Step 5: a few lines in your own instructions

Add these to the instructions you read at the start of every session (for Claude Code, ~/.claude/CLAUDE.md), in your own words:

Check your build

1. m status says you're paired and the relay answers. 2. m send "Test" "From your agent's self-check" arrives on the phone, with the words. 3. m ask "Pick one" "A" "B": your person taps B, and m wait prints { "type": "answer", "action": "B", … }. 4. Your person writes something in M, and m inbox prints it as a thought.

If any of these fails, m status and the relay's error message ({ "error": "…" }) usually say why: 401 means the signature or the clock is off, 402 that your person's trial or subscription has ended (tell them; what they send you still arrives), 404 that the pair has ended, 409 that the phone hasn't confirmed yet, and 429 that you've sent a thousand things today. Fix your script, not your person's phone.

Part 2: more of M (once Part 1 works)

Each of these is one more sealed box type to the phone. Their exact fields are in the table below. Build them when your person wants them:

| Command | Box | What your person sees | |---|---|---| | m desk <file.json> | { type: "desk", items: [{ id, title (30 max), detail, waitsOnYou }] }, the whole desk each time, push: "silent" | M's main screen: what you're working on, with what waits on them first, and Done and Reply on each | | m horizon <file.json> | { type: "horizon", items: [{ title, at, to, place, color }], span }, or { type: "horizon", end: true } | The day as one line on their lock screen | | m remind --at HH:MM "title" ["body"] | { type: "remind", remindId, atTime, title, body }, or cancel: true | A reminder the phone rings at its time, then once more as urgent if they don't answer | | m checkin | { type: "checkin" }, push: "silent" | Nothing: the phone sends fresh signals | | m remind-me --at HH:MM "text" | { type: "remind-me", atTime, text } (atTime in seconds since 1970), push: "silent" | Nothing: the phone schedules it, and it comes back to you at its time as a self-reminder |

Plan my day. At the start of your person's day (07:45 unless they've changed it), if your session is open and the line is quiet, M suggests you plan the day and put it on their lock screen. The suggestion arrives as a plan item with its own short guide: set up the horizon with m horizon, then m remind-me yourself to do the afternoon's.

Pictures from your person arrive as a thought with picture: { pictureId, parts, bytes, sha256 }, followed by that many boxes of raw bytes, each taken with part: "<pictureId> <n>/<of>". Join them in order into inbox/<pictureId>.jpg and check the SHA-256. Before using any id from the phone in a file name, keep only letters, digits, - and _.

Part 3: the morning paper (once the line works)

Early each morning (04:30 your person's time, unless they've changed it or turned it off in M), if your session is open and the line has been quiet for an hour, M sends you a suggestion: a morning item with today's date and a short guide to making your person a morning paper, a page waiting on their phone when they wake. It comes from M, not from your person. Whether to make one is yours to judge, and the guide begins with how: it's best done when nothing else is going on.

Limits