Welcome to the Arcjet JavaScript SDK! We're excited to have you contribute.
Please review the guidelines we have below to help us in accepting your contribution.
We recommend using Dev Containers to provide a fully configured development environment.
- Prerequisites
- Docker
- VS Code or a compatible editor.
- Dev Container extension
- Open in Dev Container
- Clone the repository
- Open the project in VS Code.
- When prompted, click "Reopen in Container" or run "Dev Containers: Open Folder in Container" command
- VS Code will build and start the dev container (this may take a few minutes the first time)
- Ensure you have signed commits enabled. We recommend 1Password's Sign Git commits with SSH.
That's it! You're all set to start making changes.
New adapters are added to the root of this monorepo in the format of
arcjet-NAME_OF_ADAPTER and the package name is @arcjet/NAME_OF_ADAPTER.
For example, arcjet-sveltekit is the directory for the @arcjet/sveltekit
package.
Each new adapter should come with an example application in
arcjet/examples, not in this
repository. See Examples.
New adapters (and any other new package) must also be wired into our release and publish pipeline before they can ship. See Adding a new package for the full checklist. We can help you make these changes if you need it.
@arcjet/guard integrates with third-party SDKs through subpath exports rather
than separate packages, one per vendor SDK major:
@arcjet/guard/<vendor-sdk>/v<major>. vercel-ai/v7 is the first.
Three properties of that shape are deliberate, and worth knowing before you change them:
- Vendor-prefixed, naming the SDK being integrated rather than the feature.
A feature-named namespace such as
/aiassumes there is only ever one AI SDK worth integrating, and leaves nowhere to put a second vendor whose model of the same feature differs.vercel-eve/v0bears this out: Eve is filesystem-first, with onedefineToolper file and no author-controlled call site, so its enforcement points are a channel-boundary screen and a connection-level approval gate thatvercel-ai/v7has no equivalent of.mastra/v1is the same idea on a different SDK: Mastra already runs channels throughprocessInputand treatsrequireApprovalas human HITL, so its helpers areguardTool,guardProcessor, andguardHooks.claude-agent-sdk/v0is the same idea again: authored tools aretool()handlers, inbound isUserPromptSubmit, and unwrapped built-ins arePreToolUse—canUseToolis not a policy gate.langgraph/v1is Graph API (StateGraph+ToolNode): authored tools areguardTool, unwrapped / MCP tools go throughguardToolNode, andinterrupt()is HITL not policy.createReactAgentis deprecated; do not build on it. LangChaincreateAgent/wrapToolCallislangchain/v1.langchain/v1is JScreateAgent+createMiddleware({ wrapToolCall }): the authored deny point isguardTool(plainArcjetDenialResult;baseHandlerwraps it in a successToolMessage), MCP / unwrapped / runtime-discovered tools go throughguardMiddleware'swrapToolCall(it MUST return a realToolMessage— a bare object is the reducer-crash case; do not setstatus: "error"; do not throw), inbound is screened beforeagent.invoke(SDK middleware that is notwrapToolCallis not Guard), andhumanInTheLoopMiddleware/interrupt()is HITL not policy. Policy sits onwrapToolCallonly — do not deny inafterModel. There is noguardInboundand noguardApproval. Correlation isconfigurable.thread_id(what wrapToolCall sees as of langchain 1.2.34), then caller-ownedsessionId/conversationId. Server-side provider tools and headless.implement()tools are out of scope. Do not add@langchain/langgraphas a new peer. There is no unversioned@arcjet/guard/langchainalias. Docs slug is/guards/langchain-js/, not/guards/langchain/(the live Python page).openai-agents/v0is textAgent+run()/Runner+ authoredtool({ execute }): the runner-facing deny point isFunctionTool.invoke(the SDK closes overexecute), inbound is screened beforerun()(SDKinputGuardrailsare not Arcjet), andneedsApprovalis HITL not policy. There is no ToolNode, noguardInbound, noguardApproval, and noguardHooks— hosted tools, MCP, handoffs, andagent.asTool()skip the authored-executepath.RunContexthas no session / conversation id; correlation is a field the integrator puts onrunContext.context.genkit/v1is JSgenkit()+ai.defineTool+ai.generate: the authored deny point is thedefineToolhandler (wrap the returnedToolAction;generate()calls it as a function), unwrapped / MCP / filesystem-injected tools go throughguardMiddleware'stoolhook (it denies by returning a completedToolResponsePartwithout callingnext()— notToolInterruptError), inbound is screened beforegenerate()(middlewaremodelis not Guard), andinterrupt()/defineInterrupt/toolApprovalis HITL not policy. There is noguardInboundand noguardApproval. Correlation is a field the integrator puts ongenerate({ context }). Do not wrap Go / Python Genkit. Do not double-wrap with@arcjet/guard/vercel-ai/v7.strands-agents/v1is JS@strands-agents/sdkAgent+tool({ callback })+ Plugin /addHook: the authored deny point is_callback(wrap both_callbackand ZodTool's_functionTool._callback;stream()is what the executor calls), unwrapped / MCP / vended tools go throughguardHooks'BeforeToolCallEvent(deny by settingevent.canceltoJSON.stringify(ArcjetDenialResult)— do not useBeforeToolsEvent.cancel, which skips per-tool hooks), inbound is screened beforeinvoke()/stream()(there is no inbound hook), andevent.interrupt()is HITL not policy. There is noguardInboundand noguardApproval/guardInterrupt. Correlation is a field the integrator puts oninvocationState(correlationId, thensessionId, thenrequestId). Never mint. Never readtraceId. Never useSessionManageroragent.id. Do not wrap the Python SDK. Do not double-wrap with@arcjet/guard/vercel-ai/v7or@arcjet/guard/langgraph/v1. There is no unversioned@arcjet/guard/strands-agentsalias. Docs slug is/guards/strands-agents/.tanstack-ai/v0ischat({ middleware })+ChatMiddleware.onBeforeToolCall: there is noguardTool(a throw fromexecuteis swallowed into{ error }and is not a usable deny envelope), tool calls go throughguardMiddleware'sonBeforeToolCall(default DENY is{ type: "skip", result: ArcjetDenialResult }; optionalonDeny: "abort"only; do not throw), inbound is screened withguard()beforechat()(guard()fails open — checkhasFailedOpen()), andneedsApproval/defineInterrupt/onInterruptBoundaryis HITL not policy. After a human yes, Guard still runs. Put Arcjet first in the middleware array —onBeforeToolCallis first-win; iftoolCacheMiddlewareskips first, Guard never runs. Brand-skip tools already wrapped by a siblingguardToolsoguardMiddlewaredoes not double-call. Inboundguard()beforechat()is a separate call and does not brand tools. Correlation is a caller-owned id from helper options orchat({ context }). Never mint. Neverctx.threadId. NevertraceId/requestId/streamId. There is no unversioned@arcjet/guard/tanstack-aialias and no/v1until TanStack AI ships 1.x. Do not double-wrap with@arcjet/guard/vercel-ai/v7. Do not name anythingcontentGuardMiddleware. Docs slug is/guards/tanstack-ai/. - Flat — a single level under
@arcjet/guard, no further nesting. - Explicitly versioned, with no unversioned alias.
@arcjet/guard/vercel-aidoes not resolve, and neither does a wildcard./vercel-ai/*. An alias would change meaning under a consumer the moment a new major ships, which is the exact failure the version segment exists to prevent. Its absence is asserted by a test, not left to convention.
Two more constraints hold the layout together. The helpers that are not tied to
any AI SDK live in src/agents/ and must stay that way: ESM resolves the whole
reachable import graph, so anything re-exporting a vendor-coupled function pulls
that vendor's SDK in even if it is never called. A test walks the transitive
import graph to enforce it. That layer has no exports entry of its own — it
reaches users only re-exported from a vendor namespace — because a public path
is a compatibility commitment and one integration is not enough evidence to make
it. Vendor SDKs are declared as optional peer dependencies, so guard users
who never touch an AI SDK are unaffected.
-
Create
arcjet-guard/src/<vendor-sdk>/v<major>/(e.g.src/acme-sdk/v2/). The version segment names the SDK's own major, so an SDK that has not reached 1.0 getsv0— that is why the Eve namespace isvercel-eve/v0, and whyv1is added additively at its GA rather than assumed now. -
Export the integration helpers — at minimum a wrapper equivalent to
guardTooland a way to get context to it. -
Re-export the shared layer with
export * from "../../agents/index.ts". Re-export it rather than wrapping it: wrappers break===identity across namespaces and give every shared symbol a second implementation to keep in sync. -
Add an
exportsentry inarcjet-guard/package.json:"./acme-sdk/v2": { "types": "./dist/acme-sdk/v2/index.d.ts", "import": "./dist/acme-sdk/v2/index.js" }
-
If the SDK is a new dependency, declare it in
peerDependenciesand mark it optional inpeerDependenciesMeta. -
If the namespace imports its SDK for types only, add a check to
arcjet-guard/scripts/test-peers-absent.mjs(and atype-only.test.ts). CI runs that script after unit tests and fails if atype-only.test.tsexists with no matching check, or the other way around. Skip this whensrc/tests value-import the SDK —vercel-ai/v7does, and has no type-only scan.
No changes to the shared layer, the build config, or the root export are
required — tsdown runs with unbundle: true, so a new directory under src/
is picked up with its structure preserved.
Note
pnpm does not reliably honour peerDependenciesMeta.*.optional
(pnpm#5152,
pnpm#8142), particularly under
--strict-peer-dependencies. This is documented for users in the
@arcjet/guard README rather than worked around, because the workaround
would mean giving up optional peers for everyone.
Do not add application examples under examples/ in this repository. They
live in arcjet/examples (moved in
#6217; remaining examples
are landing in arcjet/examples#193).
Agents: see the root AGENTS.md for the same rule.
Scaffold new examples in that repo with the framework's recommended tool,
following its CONTRIBUTING.md and the canonical example pattern. Do not
restore .github/workflows/reusable-examples.yml here.
Publishing to npm is mostly automated, gated behind a manual approval, and requires two people. Packages authenticate to npm with trusted publishing (OIDC) rather than long-lived tokens, and every package is published with provenance.
The workflow is defined in
publish.yml. It runs in two jobs: a
preflight job that validates the request (no approval needed) and a gated
publish job that runs only after a second person approves the npm-publish
GitHub environment.
- Release Please keeps a release pull request up to date with changelogs and version bumps. Some landed PRs, notably dependency updates, do not trigger it to run, so make sure to land something real after those.
- A person approves and merges that release PR. When it lands, Release Please
creates the
vX.Y.Ztag and GitHub release notes. - A person goes to Actions -> Publish -> Run workflow, selects the release tag,
and chooses the
latestdist-tag. - The
preflightjob runs immediately (no approval) and writes a summary of exactly what will be published. It fails the run early if the tag is not a release tag, the package versions do not all match the tag, or a non-stable version is being sent tolatest. - Another team member is asked by GitHub to approve the gated
publishjob, and one person does, optionally with a comment. - GitHub publishes to npm, which takes about 5 minutes.
Release candidates are published to the rc dist-tag (never latest) so they
can be validated in production without becoming the default install. Unlike a
stable release, an rc is cut manually:
- Create a release branch, e.g.
release/1.10.0-rc. - On that branch, bump every workspace package to the rc version (e.g.
1.10.0-rc.0), keeping them in lockstep — all package versions, the internal exact-pin dependencies, and thex-release-please-versionconstants tracked asextra-filesin release-please-config.json. Thepreflightjob asserts every package version equals the tag, so any package left behind will fail the run. - Tag the release commit
v1.10.0-rc.0and push the branch and tag. - Go to Actions -> Publish -> Run workflow, select the rc tag, and be sure to
choose the
rcdist-tag. - Approve the gated
publishjob as with a stable release.
The release branch is throwaway — the stable release still comes from Release
Please on main.
Each package trusts the GitHub Actions workflow to publish it via OIDC, so no
npm tokens are stored anywhere. This is configured once per package on npm, at
https://www.npmjs.com/package/<package-name>/access -> Trusted Publisher ->
edit, with these values:
- Publisher: GitHub Actions
- Organization or user:
arcjet - Repository:
arcjet-js - Workflow filename:
publish.yml - Environment name:
npm-publish - Allowed actions: Allow
npm publish
The quickest way to get these right is to open an already-configured package (e.g. arcjet), hit edit, and copy the same values.
Important
A package must already exist on npm before a trusted publisher can be added to it. A brand-new package therefore needs one manual first publish before trusted publishing works — see Adding a new package.
When you add a new package (an adapter or otherwise), wire it into the release and publish pipeline. Miss one of these and the package will silently not be released, or a release run will fail:
- Release Please config. Add the package to both:
- .release-please-manifest.json — an entry with the current release version, so it stays in lockstep.
- release-please-config.json — a
packagesentry (componentset to the npm name,skip-github-release: true, plusextra-filesfor any in-source version constants) and the package's npm name in thelinked-versionscomponentslist.
- Publish workflow. Add
--workspace @arcjet/<name>to the correct dependency level in publish.yml. Levels publish in dependency order, so the package must sit in a level after all of its internal dependencies. - First publish is manual. Because trusted publishing cannot be configured
until the package exists on npm, build the package and publish it once by
hand (
npm publish --workspace @arcjet/<name>), then configure trusted publishing for it. - After that, it publishes automatically alongside everything else.
Versioned Agent Skills ship inside npm packages via
TanStack Intent. Do not add new skills to
arcjet/skills for JS/TS guidance — that
repo is the older marketplace install. JS skills live next to the docs they
were derived from:
- General request / CLI / MCP / Guard routing:
arcjet-skills/ - Guard vendor integrations:
arcjet-guard/skills/
Each SKILL.md must declare sources pointing at those docs. CI runs
intent validate and a conservative stale check: if a source file changes
and the skill does not, the pull request fails so a human can decide whether
the guidance still holds.
New skill-shipping packages need the tanstack-intent keyword and a
skills/ entry in files so the tarball includes them. Consumers allowlist
packages in package.json#intent.skills and load one skill per task. Editor
hooks are convenience, not a security boundary.