@upstash/agentkit-eve
Upstash AgentKit for Eve, the Vercel agent framework. You drop these into
your agent/ tree: memory tools, Redis-Search tools, a rate-limit gate, an
Upstash Box sandbox backend, and cached tools.
pnpm add @upstash/agentkit-eve @upstash/redis
# only if you use the sandbox backend:
pnpm add @upstash/boxAdd these to your existing eve project; eve and your AI-SDK provider are already installed there.
Memory tools
Long-term memory the model reads and writes itself: recall_memory and save_memory, one file each.
// agent/tools/recall_memory.ts
import { defineMemoryRecallTool } from "@upstash/agentkit-eve";
export default defineMemoryRecallTool({
userId: (_, ctx) => ctx.session.auth.current?.principalId ?? ctx.session.id,
});// agent/tools/save_memory.ts
import { defineMemorySaveTool } from "@upstash/agentkit-eve";
export default defineMemorySaveTool({
userId: (_, ctx) => ctx.session.auth.current?.principalId ?? ctx.session.id,
});Options & the userId tenant boundary
userId(required) — a string, or(input, ctx) => string.topK— max memoriesrecallreturns.minScore— BM25 relevance floor.redis— defaults toRedis.fromEnv().
userId is the only tenant boundary (required, non-empty, no :). Derive it from eve's verified
session auth — ctx.session.auth.current?.principalId — not from anything the client supplies.
Configure a real authenticator (vercelOidc(), an OIDC/JWT provider like Clerk, …) so principalId
is trustworthy; the ?? ctx.session.id fallback only applies to unauthenticated requests. Memories
are stored at agentkit:memory:<userId>:<id>.
Search tools
search / aggregate / count over an Upstash Redis Search index; the model-facing descriptions are
generated from your schema.
// agent/tools/search_books.ts
import { s } from "@upstash/redis";
import { defineSearchTools } from "@upstash/agentkit-eve";
export default defineSearchTools({
schema: s.object({ title: s.string(), author: s.string().noTokenize(), year: s.number() }),
indexName: "books",
}).search; // aggregate_books.ts → .aggregate, count_books.ts → .countOptions & the one-file-per-tool rule
schema(required) — built withsfrom@upstash/redis.indexName— defaults to"agentkit:search"; ties all three tools to one index.prefix— key prefix for indexed JSON docs (defaults to"<indexName>:").defaultLimit— default page size forsearch(10).redis— defaults toRedis.fromEnv().
Each tool file must be self-contained, so call defineSearchTools in each one and export the member
you want — repeat the same schema + indexName across search_books.ts / aggregate_books.ts /
count_books.ts. The index is created reactively on first use, and each returned tool is already
defineTool-branded.
Rate limiting
A ready AuthFn that throttles inbound requests. Drop it into your channel's auth walk ahead of
your real authenticators.
// agent/channels/eve.ts
import { createRateLimitAuth, Ratelimit } from "@upstash/agentkit-eve";
import { localDev, vercelOidc } from "eve/channels/auth";
import { eveChannel } from "eve/channels/eve";
export default eveChannel({
auth: [
createRateLimitAuth({
limiter: Ratelimit.slidingWindow(20, "1 m"),
identifier: (req) => req.headers.get("x-forwarded-for") ?? "anonymous",
}),
localDev(),
vercelOidc(),
],
});Options and the required identifier
limiter(required) — e.g.Ratelimit.slidingWindow(20, "1 m")orfixedWindow(...).identifier(required) — a string, or(request) => string. There's no implicit"global": one shared bucket lets a single abusive caller exhaust the window for everyone, so derive it per request (an auth user id, an API key, orx-forwarded-forfor per-IP).prefix— base key prefix; keys are<prefix>:<identifier>(defaultagentkit:rateLimit).message— 403 body when over the limit.redis— defaults toRedis.fromEnv().
It's a gate: under the limit it returns null to fall through to the next AuthFn; over it throws a
403.
Why only POST requests are counted
eve runs each turn as two authenticated requests: the message POST (which invokes the model) and a
follow-up GET …/stream that opens the reply stream. The auth walk runs on both, so counting both
would charge every turn twice. createRateLimitAuth counts only the POSTs, so one turn costs one
token: a Ratelimit.slidingWindow(20, "1 m") allows 20 turns per minute, not 10. The session-read
GETs pass through unthrottled.
Code-execution sandbox
A drop-in replacement for Eve's vercel() backend, powered by Upstash Box. Swap the import and keep
the rest of your sandbox file the same.
// agent/sandbox.ts
import { defineSandbox } from "eve/sandbox";
import { upstash } from "@upstash/agentkit-eve/sandbox"; // was: eve/sandbox/vercel
export default defineSandbox({
backend: upstash({ runtime: "node", size: "medium" }),
revalidationKey: () => "repo-bootstrap-v1",
async bootstrap({ use }) {
const sandbox = await use({ networkPolicy: "allow-all" }); // open egress to install packages
await sandbox.run({ command: "apt-get install -y jq" });
},
async onSession({ use }) {
await use(); // inherits the secure deny-all default
},
});Config: Box's BoxConfig
upstash(config) takes the @upstash/box BoxConfig verbatim — whatever you'd pass to
Box.create({...}): runtime, size, apiKey (defaults to UPSTASH_BOX_API_KEY), keepAlive,
initCommand, env, skills, mcpServers, timeout, … — plus an optional redis (defaults to
Redis.fromEnv()). networkPolicy is not a config knob (see below).
@upstash/box is an optional peer dependency — only needed when you import
@upstash/agentkit-eve/sandbox.
Security: network egress is deny-all by default
The sandbox runs untrusted, model-generated code, so open egress would mean SSRF / data exfiltration /
reaching your own infrastructure from inside the box. Open it per-session — in bootstrap's use(...)
or the session use(...) — never as a config knob. Note that env passed to upstash({ env }) is
readable by code running in the box; don't pass secrets you wouldn't want it to see.
Brokering credentials (injecting headers)
Box network policies are plain domain/CIDR allow-lists. Eve's per-domain firewall rules (transform
header injection, forwardURL) have no Box equivalent, so passing them in use({ networkPolicy })
throws rather than silently sending the request unauthenticated:
// ❌ throws — Box can't inject headers via a per-session policy
export default defineSandbox({
backend: upstash({ runtime: "node" }),
async onSession({ use }) {
await use({
networkPolicy: {
allow: { "api.example.com": [{ transform: [{ headers: { authorization: "Bearer …" } }] }] },
},
});
},
});Broker credentials with Box's attachHeaders instead (set at backend creation; a proxy on the box
injects them), and open the domain with a plain allow-list:
// ✅ headers injected at the firewall; the secret never enters the box
export default defineSandbox({
backend: upstash({
runtime: "node",
attachHeaders: { "api.example.com": { Authorization: "Bearer …" } },
}),
async onSession({ use }) {
await use({ networkPolicy: { allow: ["api.example.com"] } });
},
});Lifecycle: one box per conversation
Reuse — eve re-opens a session several times per turn; the backend reattaches to the same Box
instead of creating a new one each time. Boxes default to Box's pause-based idle lifecycle
(keepAlive: false) — auto-paused when idle, resumed on reattach, reaped by Box. Pass keepAlive: true
only for an always-running box you manage yourself.
Template registry — eve builds your template (seed files + bootstrap) at build/startup, but
session creation runs per request in a different process, so the snapshot id is stored in a durable
Redis registry (redis, defaulting to Redis.fromEnv()). Eve roots its tools at /workspace while a
Box session lives at /workspace/home; the backend bridges the two automatically.
Cached tools
Like Eve's defineTool, but the execute result is memoized in Redis.
// agent/tools/get_weather.ts
import { z } from "zod";
import { defineCachedTool } from "@upstash/agentkit-eve";
export default defineCachedTool({
description: "Get the current weather for a city.",
inputSchema: z.object({ city: z.string() }),
execute: async ({ city }) => fetchWeather(city),
toolName: "get_weather",
userId: (_, ctx) => ctx.session.auth.current?.principalId ?? ctx.session.id,
});Options
description/inputSchema/execute— the usualdefineToolfields;execute's result is memoized.toolName(required) — the tool segment of the cache key.userId(required) — a string, or(input, ctx) => string; scopes the cache per user.ttlSeconds— per-result TTL (default: no expiry).redis— defaults toRedis.fromEnv().
Keys are agentkit:toolCache:<userId>:<toolName>:<hash>.
Working with eve's agent/ files
eve's runtime snapshots each tool/channel/hook file and resolves only package imports from it — it
does not include shared agent/-source modules (e.g. a agent/lib/redis.ts). So inside agent/:
- Import only from packages, never from other
agent/files. - Lean on the defaults —
redisdefaults toRedis.fromEnv()in every helper, so you almost never pass it. - Repeat config (schema, names) per file rather than sharing a module.
Shared app code (e.g. a seeder a page calls) lives in your project lib/, imported by the app — not by
agent/ files.
Testing
Tests run against a real Upstash Redis (and a real Box when UPSTASH_BOX_API_KEY is set); only LLM
calls are mocked. Set UPSTASH_REDIS_REST_URL / UPSTASH_REDIS_REST_TOKEN (suites skip when absent).
License
MIT