The best automation interface is one you already use every day.
I keep coming back to this. I've written about triggering agents from GitHub issues and wiring remote agents into every new repo. Both are great, but you still gotta open GitHub and click around. This is fine on desktop but on mobile it's a lot of tip taps.
So what's a clean, easy solution for mobile?
Click share + Send Email
It's on every device you own, it's async by default, and it's usually the only sharing mechanism that exists in all these mobile apps.
So I got to thinking:
What if sending an email could kick off an agent, and the reply was a pull request?
Turns out to be easier than I thought. With an AWS SES inbound email receiver plus somewhere to execute your agentic workflows, Bob's your uncle.
I run these workflows in GitHub Actions, Netlify Agent Runners, and Cursor Agents.
If you'd like help getting something like this set up for your work, drop me a line.
I send an email to repo@yourdomain.com. The subject names the repo plus a short title, the body is whatever I dictate into my phone. A few minutes later there's a GitHub issue capturing the ask and, when the request needs code, a PR doing the work.
flowchart TB Email[Email to repo@yourdomain.com] --> SES[SES receipt rule] SES --> S3[(S3: raw MIME)] SES --> Lambda[Lambda: verify + match repo] Lambda -->|repository_dispatch| GH[GitHub Actions] GH --> Agent[AI agent works the task] Agent --> Issue[GitHub issue] Agent --> PR[Pull request]
Five moving parts, each doing one small job:
repository_dispatch turns that into a workflow trigger in the matched repo, with the email as payload.The email is the remote control, the issue is the durable record, and the PR is the artifact.
Inbound email is the part most people don't realize SES can do. To receive mail for a domain you need three things:
The MX record is the piece that's easy to forget:
yourdomain.com. MX 10 inbound-smtp.us-east-1.amazonaws.comThen a receipt rule set that stores the raw message in S3 and fires a Lambda. Order matters here: write to S3 first so the Lambda can read the full MIME.
{
"Rule": {
"Name": "repo-inbound",
"Enabled": true,
"Recipients": ["repo@yourdomain.com"],
"ScanEnabled": true,
"Actions": [
{
"S3Action": {
"BucketName": "my-inbound-email",
"ObjectKeyPrefix": "repo/"
}
},
{
"LambdaAction": {
"FunctionArn": "arn:aws:lambda:us-east-1:1234567890:function:email-to-agent",
"InvocationType": "Event"
}
}
]
}
}ScanEnabled: true turns on SES spam and virus scanning. You'll want those verdicts in a second.
Important! Email is trivially spoofable. If anyone on the internet can email repo@yourdomain.com and start an agent that opens PRs against your repos, you've built a remote code execution vector with a friendly UX.
The Lambda's first job is to say no.
SES hands you the receipt inside the event, including the spam, virus, SPF, DKIM, and DMARC verdicts it already computed. Check all of them, then check the sender against an allowlist:
const ALLOWED_SENDERS = new Set([
'you@example.com',
])
function isTrustworthy(sesNotification) {
const { receipt, mail } = sesNotification
const v = receipt
// SES already scored the message. Trust nothing that failed.
const verdictsPass =
v.spamVerdict.status === 'PASS' &&
v.virusVerdict.status === 'PASS' &&
v.spfVerdict.status === 'PASS' &&
v.dkimVerdict.status === 'PASS' &&
v.dmarcVerdict.status === 'PASS'
const from = mail.commonHeaders.from?.[0] || ''
const sender = extractEmail(from).toLowerCase()
return verdictsPass && ALLOWED_SENDERS.has(sender)
}DKIM and DMARC are what make this safe. A spoofed From header will fail DKIM signature validation and DMARC alignment, and SES reports that failure to you for free. The allowlist is the second lock: even a perfectly valid email from a stranger doesn't get to run your agent.[1]
If isTrustworthy returns false, log it and stop. No dispatch, no agent, no PR.
Once the message is trusted, pull the raw MIME from S3 and turn it into structured fields. Don't hand-roll a MIME parser; mailparser handles multipart bodies, encodings, and attachments:
import { simpleParser } from 'mailparser'
import { GetObjectCommand, S3Client } from '@aws-sdk/client-s3'
const s3 = new S3Client({})
async function emailToTask(bucket, key) {
const obj = await s3.send(new GetObjectCommand({ Bucket: bucket, Key: key }))
const parsed = await simpleParser(obj.Body)
return {
subject: parsed.subject?.trim() || '',
request: parsed.text?.trim() || '',
from: parsed.from?.value?.[0]?.address,
}
}That's the whole contract: subject = which repo (plus a short title), body = what you want done. Same way you'd brief a coworker, which is why email works so well here.
The subject has to answer one question before anything else: which repo? Match it against the list of repos you own:
function resolveRepo(subject, repos) {
const words = subject.toLowerCase()
return repos.find(repo => words.includes(repo.name.toLowerCase()))
}Exact includes works when you type. Dictation mangles repo names: my-site comes out as "my site", pdf-tools as "PDF tools". In practice you want fuzzier matching that normalizes dashes and case, scores candidates, and requires a clear winner. The important part is the failure mode: if there's no confident match, do nothing. An agent guessing which repo you meant is worse than no agent at all.
Keep the matched repo's full owner/repo name around. That's the identifier GitHub expects in the dispatch URL.
repository_dispatchrepository_dispatch is the GitHub feature that makes this whole thing click. It lets an external system trigger a workflow and hand it an arbitrary JSON client_payload. That payload is how the email contents reach the agent, and the repo you matched in step 3 is where the dispatch goes.
async function triggerAgent(repoFullName, task) {
const res = await fetch(`https://api.github.com/repos/${repoFullName}/dispatches`, {
method: 'POST',
headers: {
Authorization: `Bearer ${process.env.GITHUB_TOKEN}`,
Accept: 'application/vnd.github+json',
'X-GitHub-Api-Version': '2026-03-10',
},
body: JSON.stringify({
event_type: 'email-repo-task',
client_payload: {
subject: task.subject,
request: task.request,
requested_by: task.from,
},
}),
})
if (!res.ok) {
throw new Error(`Dispatch failed: ${res.status} ${await res.text()}`)
}
}Use a token with only the permissions the workflow needs. This token can start workflows in any repo it can reach, so treat it like the sensitive credential it is: store it in the Lambda's environment via your secrets manager, never in code.
Now the GitHub side. The workflow listens for the email-repo-task event and reads the email fields straight out of github.event.client_payload:
name: Email Repo Task
on:
repository_dispatch:
types: [email-repo-task]
permissions:
contents: write
pull-requests: write
issues: write
jobs:
task:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Work the task
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: |
A trusted email asked for work on this repo.
Subject: ${{ github.event.client_payload.subject }}
Request:
${{ github.event.client_payload.request }}
First open a GitHub issue summarizing the request so there is
a durable record. If it calls for code changes, implement them
on a branch and open a PR that references the issue. If it's
just a question, answer it in the issue instead.The prompt decides the outcome. A dictated bug report turns into an issue plus the PR that fixes it; a question gets answered in the issue. Either way you reply like you would to any contributor, in review comments, from the same phone you sent the original email on.
One guardrail: the prompt is attacker-influenced text right up until the allowlist check passes. The sender allowlist in Step 2 is what lets you treat the body as a trusted brief instead of hostile input. Keep the workflow's permissions minimal, scoped to issues and PRs, never merging.[2]
Fixing a bug by talking at your phone is the fun one, but the pattern generalizes to any "capture a thought, get an artifact back" workflow:
None of these need you at a computer, just a thought and a way to send it.
I could have used a chat bot, a custom app, a Slack slash command. I keep landing on email for the same reasons every time:
The infrastructure behind it isn't trivial (SES, a Lambda, a token, a workflow) but you build it once and the interface disappears. Which brings it back to the opening line: the best automation interface is the one you already use every day, and for most people that's still their inbox.
Send an email, get a PR, go do something else.