Content

How to Build a Gmail Triage Agent With Jev (Full Code)

A step-by-step build of a Gmail triage agent: Jev decides what each email is and how urgent it is, and Swytchcode reads the inbox and applies labels. Full Node.js code, the exact Jev questions we used, and the mistakes worth avoiding.

Chaitrali Kakde

DevRel Engineer

AI AgentOct 10, 2026

Key takeaways

  • -Jev answers four narrow questions per email (actionable, category, urgency, next action) in one call, with probabilities you can branch on.
  • -Swytchcode handles the Gmail side: listing messages, fetching them and applying labels, with OAuth tokens kept out of your code.
  • -Only the five Gmail methods you add to tooling.json can run, so a new triage agent can't send or delete mail by accident.
  • -Run in print-only mode first. Turn on label writes only after you've compared Jev's answers against your real inbox.
  • -Keep the email text you send to Jev short. Sender, subject, date and the snippet are usually enough, and fewer tokens means lower cost.

Every morning starts the same way. Forty new emails. Three of them matter today. You have to open most of the other thirty-seven to find out which three.

We wanted to see if Jev, TypeSafe AI's new decision model, could do that first pass for us. Not write replies, not summarize threads. Just answer: does this email need me, what is it about, how urgent is it, and what should happen next?

This post is the full build. By the end you'll have a small Node.js agent that reads your Gmail inbox, asks Jev four questions about each email, sorts the inbox by what actually needs you, and (once you trust it) adds labels like Jev/Urgent in Gmail.

The test inbox we used belongs to a chartered accountant's practice in India, so the categories are things like client queries and tax notices. Swap them for your own; the code doesn't care.

What you'll build

Here's the whole flow:

Gmail inbox
   │  swytchcode exec gmail.user.messages.get   (list)
   │  swytchcode exec gmail.user.messages.get1  (fetch one)
   ▼
{ from, subject, date, snippet }
   │
   ▼
Jev: 4 questions in one call
   │
   ▼
{ actionable, category, urgency, next_action } + confidence
   │
   │  swytchcode exec gmail.user.labels.create  (make Jev/* labels)
   │  swytchcode exec gmail.user.modify.create  (apply them)
   ▼
Labelled, sorted inbox

Two pieces do the work:

  • Jev makes the decisions. It never touches Gmail.
  • Swytchcode runs every call, to Jev and to Gmail. It stores the credentials, only runs the methods you've allowed, and keeps a log of every call.

That split is deliberate. A model deciding what should happen is fine. A model holding your Gmail token and calling APIs directly is how inboxes get emptied by accident. We've written about why in why your AI agent calls the wrong API.

Why Jev for this, and not an LLM?

You could do all of this with GPT or Claude. Plenty of people do. Three things pushed us to Jev for this job.

The output is always usable. Ask an LLM to "return JSON with a category" and most of the time you get JSON with a category. Sometimes you get a category that isn't on your list, or a polite sentence before the JSON. Jev can only answer with options you defined, so category is always one of your keys.

It's fast enough to run on every email. TypeSafe reports 70 to 500 ms per call, and all four questions are answered in that one call. Triage is something you want to run constantly, not in a nightly batch.

It gives you a confidence number. This is the part we didn't expect to like so much. When Jev is unsure, it tells you, and you can leave that email alone instead of mislabelling it.

TypeSafe launch graphic for Jev, the first System One model

Jev launched on September 15, 2026 as TypeSafe's first System One model. Source: TypeSafe AI

If you're new to Jev, read our full guide to how Jev works first. It covers the three question types we use below.

What you need

  • Node.js 20 or newer.
  • A Jev API key from console.typesafe.ai. You'll paste it into Swytchcode once, not into your code.
  • The Swytchcode CLI. Run npx swytchcode, or install it globally with npm install -g swytchcode, then sign in with swy login.
  • A Gmail account you're comfortable testing on. Use a secondary one if you can.

Step 1: Set up the project

Create a folder, add a package.json with "type": "module", and install the Swytchcode JavaScript runtime:

mkdir jev-gmail-triage && cd jev-gmail-triage
npm init -y && npm pkg set type=module
npm install @swytchcode/runtime

Then set up Swytchcode in the same folder:

swy init --mode=sandbox

This creates a .swytchcode/ folder with a tooling.json inside. The mode lives in that file, so your code doesn't need to know or set it. Start in sandbox while you test.

There's no .env file in this project. No Jev key, no Gmail token, no mode flag. Swytchcode keeps credentials in its own local store (~/.swytchcode/credentials.db), outside your project folder, and reads them when a call runs. Nothing secret ends up in your repo.

Step 2: Connect Jev and Gmail

Pull in both integrations and allow only the methods this agent needs:

swy get jev
swy get gmail

swy add method <jev-method-id>              # evaluate questions against a state
swy add method gmail.user.messages.get      # list inbox messages
swy add method gmail.user.messages.get1     # fetch one message
swy add method gmail.user.labels.create     # create Jev/* labels
swy add method gmail.user.labels.get        # look up label IDs
swy add method gmail.user.modify.create     # apply labels to a message

swy auth connect jev      # asks for your Jev API key
swy auth connect gmail    # opens Google's sign-in in your browser
swy doctor

A few things are happening here.

swy get downloads the Jev integration and the Gmail integration. swy list methods jev shows the exact Jev method ID to add.

Each swy add method line allows exactly one method, and that matters more than it looks. Only methods listed in tooling.json can run. This agent can ask Jev questions, and it can list, read and label mail. It cannot send, forward or delete anything, because we never added those methods. If a bug in your code tries, Swytchcode refuses. Our post on tooling.json, manifest.json and policies.json explains how the three files fit together.

swy auth connect works differently per provider. Jev uses an API key, so the CLI asks you to paste it. Gmail uses OAuth, so it opens a browser. Either way the credential goes into Swytchcode's store, not your code. swy auth status shows what's connected, and swy doctor checks the whole setup.

Not sure what a method is called? swy discover "add a label to a gmail message" searches by plain English, and swy info gmail.user.modify.create shows its inputs and outputs.

Claude Code running swytchcode list and swytchcode info commands to find the right methods before executing

A coding agent using swytchcode list and swytchcode info to find the right method and check its contract before it runs anything.

Step 3: Write the Jev questions

This is the part worth spending time on. Jev is only as good as the questions you ask.

Create src/decisions.js:

export function buildQuestions() {
  return {
    is_actionable: {
      type: "noul",
      instructions:
        "Does this email require the recipient to personally do something " +
        "(reply, review, sign, file, or decide) rather than just being informational?",
    },

    category: {
      type: "choice",
      instructions: "What is this email primarily about?",
      criteria: {
        client_query: "A client asking a question or requesting work or advice",
        compliance_deadline: "A tax, GST or government notice, due date, or filing requirement",
        billing_payment: "An invoice, payment confirmation, or fee-related matter",
        internal_admin: "Practice admin: staff, scheduling, software, vendors",
        newsletter_marketing: "Newsletters, promotions, or marketing content",
        other: "Anything that doesn't clearly fit the above",
      },
    },

    urgency: {
      type: "score",
      instructions:
        "How time-sensitive is this email? Consider explicit deadlines, " +
        "regulatory due dates, and the tone of the sender.",
      criteria: ["Not urgent", "Low", "Medium", "High", "Critical"],
    },

    suggested_action: {
      type: "choice",
      instructions: "Given the content, what should happen to this email next?",
      criteria: {
        reply_today: "Needs a response within the day",
        schedule_followup: "Needs action, but can be scheduled for later",
        delegate_to_team: "Can be handed to staff rather than handled personally",
        file_for_records: "No action needed, but should be kept for records",
        ignore_or_delete: "Safe to ignore, archive, or delete",
      },
    },
  };
}

A few choices here that came from trial and error:

Describe each option, don't just name it. "compliance_deadline": "A tax, GST or government notice..." gives Jev far more to work with than a bare label.

Always include other. TypeSafe's Choice docs recommend a "none of the above" option whenever your list might not cover everything. Without it, Jev has to force a weird email into the closest box.

Ask actionable and urgency separately. A GST due-date reminder can be urgent in general but not actionable for you if it's already filed. Mixing the two into one question hides that.

Put the reader in the instructions. Saying who the recipient is ("a practicing chartered accountant") changes what counts as actionable.

Now the function that turns an email into Jev's state:

export function emailToState(email) {
  return [
    `From: ${email.from}`,
    `Subject: ${email.subject}`,
    `Date: ${email.date}`,
    "",
    (email.snippet || email.body || "").slice(0, 4000),
  ].join("\n");
}

Keep this short. Jev charges per input token (TypeSafe lists $0.042 per million), so sending whole threads with signatures and disclaimers costs more and usually doesn't help. Sender, subject, date and Gmail's snippet are enough for triage.

Step 4: Ask Jev through Swytchcode

Create src/jev.js. Because Jev is a Swytchcode integration, calling it looks exactly like calling any other API: one exec call with the method ID and the request body.

import { exec } from "@swytchcode/runtime";

// Find yours with: swy list methods jev
const JEV_METHOD = "<jev-method-id>";

export async function decide(state, questions, model = "jev-latest") {
  // Swytchcode adds your Jev key, runs the call and returns parsed JSON
  return exec(JEV_METHOD, { body: { model, state, questions } });
}

That's the whole client. No API key handling, no retry loop, no fetch. Swytchcode attaches the key you saved with swy auth connect jev, retries rate-limit errors with backoff, and logs the call next to your Gmail calls. If you want the details of what happens on each call, the execution pipeline docs walk through every step.

jev-latest tracks new Jev releases. If you want results that don't shift under you, pin a version such as jev-1.13.0.

Then flatten Jev's answers into something easy to use:

export function summarizeAnswers(answers) {
  return {
    actionable: answers.is_actionable.noul >= 0.5,
    actionable_confidence: answers.is_actionable.noul,
    category: answers.category.choice,
    category_confidence: answers.category.confidence,
    urgency_label: answers.urgency.legend[String(Math.round(answers.urgency.score))],
    urgency_confidence: answers.urgency.confidence,
    next_action: answers.suggested_action.choice,
    next_action_confidence: answers.suggested_action.confidence,
  };
}

urgency.legend maps the score back to your labels, so a score of 3 becomes "High".

Step 5: Read and label Gmail with Swytchcode

Create src/gmail.js. Same pattern: every Gmail call is one exec.

import { exec } from "@swytchcode/runtime";

const labelIds = new Map();

export async function listInboxMessageIds({ query = "in:inbox", maxResults = 20 } = {}) {
  const res = await exec("gmail.user.messages.get", { params: { q: query, maxResults } });
  return (res.messages || []).map((m) => m.id);
}

export async function getMessage(id) {
  const res = await exec("gmail.user.messages.get1", {
    params: { id, format: "metadata", metadataHeaders: ["From", "Subject", "Date"] },
  });
  const h = Object.fromEntries((res.payload?.headers || []).map((x) => [x.name, x.value]));
  return { id: res.id, from: h.From, subject: h.Subject, date: h.Date, snippet: res.snippet };
}

async function ensureLabel(name) {
  if (labelIds.has(name)) return labelIds.get(name);
  try {
    const created = await exec("gmail.user.labels.create", {
      body: { name, labelListVisibility: "labelShow", messageListVisibility: "show" },
    });
    labelIds.set(name, created.id);
    return created.id;
  } catch (err) {
    // Label already exists: look it up instead
    const list = await exec("gmail.user.labels.get", {});
    const match = (list.labels || []).find((l) => l.name === name);
    if (!match) throw err;
    labelIds.set(name, match.id);
    return match.id;
  }
}

export async function applyLabels(id, names) {
  const addLabelIds = await Promise.all(names.map(ensureLabel));
  return exec("gmail.user.modify.create", { params: { id }, body: { addLabelIds } });
}

There's no OAuth code here, no token refresh and no Gmail SDK. Each exec call checks the method is allowed in tooling.json, runs your policies, adds your Gmail credentials, makes the request and returns JSON. If something fails (a policy block, an expired login, a Gmail error), it throws, so wrap calls in try/catch where you care. If you'd rather work from Python, the Python runtime has the same model.

We ask for format: "metadata" on purpose. It returns headers and the snippet without the full body, which is all triage needs and keeps less of your mail moving around.

Step 6: Put it together

src/triage.js runs one email through Jev, sorts the results and picks labels:

import { buildQuestions, emailToState } from "./decisions.js";
import { decide, summarizeAnswers } from "./jev.js";

export async function triageEmail(email) {
  const { answers, usage } = await decide(emailToState(email), buildQuestions());
  return { email, ...summarizeAnswers(answers), usage };
}

export function sortByPriority(triaged) {
  const rank = { Critical: 4, High: 3, Medium: 2, Low: 1, "Not urgent": 0 };
  return [...triaged].sort((a, b) => {
    if (a.actionable !== b.actionable) return a.actionable ? -1 : 1;
    return (rank[b.urgency_label] ?? 0) - (rank[a.urgency_label] ?? 0);
  });
}

export function labelsFor(r) {
  const labels = [`Jev/${r.category}`];
  if (r.urgency_label === "Critical" || r.urgency_label === "High") labels.push("Jev/Urgent");
  if (!r.actionable) labels.push("Jev/No-Action-Needed");
  return labels;
}

And index.js ties it up, with a flag that decides whether labels are actually written:

import { listInboxMessageIds, getMessage, applyLabels } from "./src/gmail.js";
import { triageEmail, sortByPriority, labelsFor } from "./src/triage.js";

const writeLabels = process.argv.includes("--apply-labels");

const ids = await listInboxMessageIds({ maxResults: 20 });
const emails = await Promise.all(ids.map((id) => getMessage(id)));

const results = [];
for (const email of emails) results.push(await triageEmail(email));

for (const r of sortByPriority(results)) {
  console.log(`[${r.urgency_label.toUpperCase()}] ${r.email.subject}`);
  console.log(`   ${r.category} (${Math.round(r.category_confidence * 100)}%), next: ${r.next_action}`);
  if (writeLabels) await applyLabels(r.email.id, labelsFor(r));
}

Step 7: Run it (print first, label later)

Run it without writing anything to Gmail:

node index.js

Here's what the output looks like. We ran this first pass on six sample emails with a simple keyword-matching stand-in in place of Jev, so we could test the plumbing before spending any API calls. The format is exactly what the real Jev run prints; the judgments from real Jev are better, and the confidence numbers will vary per email. We've trimmed the list to four and changed the sender names.

[CRITICAL] Need updated CMA data for bank submission by Friday
   from: Hospital client <[email protected]>
   category: client_query (82%)   actionable: yes (90%)
   next action: reply_today (80%)

[CRITICAL] URGENT: Discrepancy notice on Form MGT-7 filing
   from: Registrar of Companies <[email protected]>
   category: compliance_deadline (82%)   actionable: yes (90%)
   next action: reply_today (80%)

[MEDIUM] Quick question about input tax credit on last month's purchases
   category: client_query (82%)   actionable: yes (90%)
   next action: schedule_followup (80%)

[NOT URGENT] Your invoice #4471 has been paid
   category: billing_payment (82%)   actionable: no (5%)
   next action: file_for_records (80%)

--- done. 6 emails triaged. ~1590 Jev input tokens used. ---

That last line is worth a look. Six emails came to about 1,590 input tokens. At TypeSafe's listed price, that's well under a hundredth of a cent.

Read through the output against your real inbox for a few days. When you're happy, turn on labels:

node index.js --apply-labels

Your inbox now has Jev/client_query, Jev/compliance_deadline, Jev/Urgent and Jev/No-Action-Needed labels you can filter on.

Make it safer before you trust it

A triage agent that only adds labels is low-risk. The moment you let it archive, reply or delete, you want more guardrails. Here's what we'd add.

Gate on confidence. Don't label anything where category_confidence is low. Leave it for a person. TypeSafe's confidence guide suggests three bands (act, check, don't act) and tells you to set the cut-offs based on how bad a wrong answer would be. Starting around 0.5 for "leave it alone" and well above that for anything that removes mail is a sensible default.

if (r.category_confidence < 0.5) {
  labels = ["Jev/Needs-Review"]; // a person decides
}

Stay in sandbox until you're sure. The project runs in the mode you picked at swy init, which is stored in .swytchcode/tooling.json. Move to production only when you've read enough output to trust it.

Add a policy before adding a write method. If you later add gmail.user.send.create1 to send replies, put a human approval policy in front of it first. Our post on setting up policy guardrails walks through it.

Check the audit log. swy audit network lists every Gmail call your agent made, and swy audit policy shows anything that was blocked or held. It's the fastest way to answer "what did it actually do last night?"

Things we got wrong the first time

We sent too much text. Our first version sent full email bodies, including long signatures and legal footers. More tokens, no better answers. The snippet plus headers worked just as well.

Our categories overlapped. We started with both client_query and client_document, and Jev split similar emails between them. Fewer, clearly different options gave steadier results.

We trusted urgency too much. Jev reads tone and dates in the text, but it doesn't know your calendar. An email saying "by Friday" sent on Thursday night needs context Jev doesn't have. Jev's own docs say it's weaker with dates and numbers, so we treat urgency as a sorting hint, not a deadline.

We forgot the labels already existed. On the second run, labels.create failed because Jev/Urgent was already there. That's why ensureLabel falls back to looking the label up.

Where to go from here

Once triage works, the same pattern stretches a long way:

  • Draft replies for the reply_today pile with an LLM, using gmail.user.drafts.create. Drafts are safe; you still press send. Our Gmail assistant example does this with the Anthropic SDK.
  • Push urgent client emails to Slack with slack.chat.postmessage.create through the Slack integration.
  • Turn compliance deadlines into calendar events with the Google Calendar integration. Our Gmail and Calendar demo is a good starting point.
  • Split categories further. Choice questions take up to 255 options, so compliance_deadline could become GST, income tax, ROC and FEMA once the basic version proves useful.

For more ideas, our Jev guide covers eight use cases across Drive, Calendar, Slack, GitHub, Stripe, Intercom and Salesforce.

FAQ

Can Jev read my Gmail directly?

No. Jev only evaluates the text you send it. In this build, Swytchcode fetches the email from Gmail and your code passes the sender, subject, date and snippet to Jev.

Does Jev send or delete emails?

No. Jev only returns decisions. Anything that changes your inbox happens through Swytchcode, and only for methods you've added to tooling.json.

How much does it cost to triage an inbox with Jev?

Jev charges $0.042 per million input tokens with free output. In our demo run, six emails used about 1,590 input tokens, which is a tiny fraction of a cent.

Do I need a Gmail API key or Google Cloud project?

Not with Swytchcode. swy auth connect gmail handles the OAuth flow, and the token is stored by Swytchcode, outside your project.

What if Jev puts an email in the wrong category?

Check the confidence score. Low-confidence results should go to a "needs review" label instead of being acted on. Then refine the option descriptions in your Choice question.

Where does my Jev API key go?

Into Swytchcode, not your code. Run swy auth connect jev and paste the key when asked. Swytchcode stores it locally and adds it to each Jev call.

Can I use Python instead of Node.js?

Yes. Swytchcode has a Python runtime, so the same Jev and Gmail calls work from Python.

Wrapping up

The whole agent is about 200 lines. Jev answers four questions per email in one call, and Swytchcode does everything that touches Gmail. Neither one has to do the other's job.

If you want to try it, start with the Jev integration and the Gmail integration, or point your coding agent at our skills file and ask it to build a Gmail triage agent with Jev.

More content