@pushary/eve
v0.3.4
Published
Phone approvals for Eve agents. Your agent asks, your user taps Approve or Deny.
Maintainers
Readme
@pushary/eve
Phone approvals for Eve agents. Your agent asks, your user taps Approve or Deny.
Full walkthrough: Human-in-the-loop for Eve
What you need
- A Pushary Partner plan, from $99 a month. Start the trial.
- An API key from Partner onboarding, set as
PUSHARY_API_KEY. - Your users install the free Pushary app (iPhone, Android). They never sign up or pay.
Quick start
npm i @pushary/eveEve discovers tools by file. Drop in two one-line files:
// agent/tools/ask-human.ts
import { pusharyAskHuman } from '@pushary/eve'
export default pusharyAskHuman()// agent/tools/connect-phone.ts
import { pusharyConnectPhone } from '@pushary/eve'
export default pusharyConnectPhone()That is it. The agent now has ask-human (approve, choose or type an answer on a phone, and wait for it) and connect-phone (returns the connect link).
Two calls is the whole integration: pusharyConnectPhone() returns a link the end-user opens once to connect their phone, and pusharyAskHuman() asks that person and waits for the answer. No answer means no.
Eve's own approval docs: Multi-tenant approvals.
Gating a tool the model cannot skip
pusharyAskHuman() is a tool the model chooses to call. That is right for "go ask
someone about this", and wrong for "this must not happen without a yes", because a
model that does not want to be interrupted can decline to call it.
For an enforced gate, use pusharyApproval() in Eve's own per-tool approval:
field. Eve evaluates it before execute runs, so there is no path around it:
// agent/tools/issue-refund.ts
import { defineTool } from 'eve/tools'
import { z } from 'zod'
import { pusharyApproval } from '@pushary/eve'
export default defineTool({
description: 'Refund an order',
inputSchema: z.object({ amount: z.number(), customer: z.string() }),
approval: pusharyApproval(),
execute: async ({ amount }) => refund(amount),
})Eve names a tool by its file path, so there is no name field; this one registers
as issue_refund.
It sits alongside Eve's built-in always(), never() and once(), which decide
statically. This one asks a person and waits for the answer.
Fail-closed: a denial, an expiry, or nobody answering all come back denied and the tool does not run. The decision is keyed on session, call and tool, so a replayed turn resolves to the same decision instead of asking twice.
For a multi-tenant product, the person who answers is the signed-in user of the session. That is the default, and you can spell it out:
approval: pusharyApproval({ externalId: (ctx) => ctx.session.auth.current?.principalId })Never take the end-user from ctx.toolInput. The model writes the tool input, so a
prompt injection could send the approval to a different customer. If your id for the
user lives somewhere else, look it up on your server from the session.
pusharyApproval<TInput>() is generic over your tool's input, so ctx.toolInput is
typed inside a question builder.
The channel
The tools above are for asking on purpose. The channel covers everything Eve already pauses on: any tool gated with approval from eve/tools/approval, and the built-in ask_question. Eve renders those as buttons in Slack. This renders them on a phone.
// agent/channels/pushary.ts
import { pusharyChannel } from '@pushary/eve'
export default pusharyChannel()PUSHARY_API_KEY=pk_...sk_...
PUSHARY_WEBHOOK_SECRET=whsec_... # decisions.getWebhookSecret()
PUSHARY_CALLBACK_ORIGIN=https://your-agent.vercel.appEve emits input.requested and parks the turn durably. The channel opens a Pushary decision carrying a signed callback URL, and nothing is held open while it waits, so an approval can sit for hours at zero idle compute. When the human taps, POST /pushary/answer verifies the webhook signature and the per-request routing signature, then resumes the parked turn with the matching inputResponses entry.
Three more routes put the rest of the session on the phone:
| Route | Does |
| --- | --- |
| POST /pushary/message | Send a follow-up, starting a session if there is none |
| POST /pushary/stop | cancel() the in-flight turn, leaving history intact |
| POST /pushary/reset | reset() the session so the next message starts clean |
Options are matched back by label and then by id, so an approval resolves to Eve's approve or deny and a select resolves to the option that was tapped. Each decision carries an idempotency key derived from the session and request id, so a replayed step never asks the same person twice. A stale answer, one whose session already moved on, returns 410 rather than failing the webhook.
Who answers
By default each tool asks the session principal, so run user-scoped auth and each end-user is their own principal. To bind a fixed end-user (single-user agents, jobs), pass one:
export default pusharyAskHuman({ externalId: 'user_123' })If there is no principal and no configured externalId, the tool throws a clear error instead of asking the wrong person.
Behavior that matters
- Fail-closed. A declined, expired, or unanswered
confirmis reported to the model as "not approved, do not proceed." Approval only happens on an explicit yes. - Serverless-safe. Each ask blocks up to 55 seconds by default (
timeoutMs). The decision stays answerable for its full lifetime.
Config
pusharyAskHuman(config?) / pusharyConnectPhone(config?) accept { apiKey?, externalId?, agentName?, timeoutMs?, baseUrl? }. apiKey defaults to process.env.PUSHARY_API_KEY.
Under the hood
Thin binding over the shared adapter kernel in @pushary/server (@pushary/server/adapters), which every Pushary framework adapter is built on. The tools use enroll + decisions.ask; the channel uses decisions.create with a signed callback URL; pusharyApproval() uses the kernel's fail-closed gate. Requires [email protected] or newer. See the adapters guide.
MIT
Example
A runnable example is in examples/.
