Vercel's AI SDK (the ai npm package, also called AI SDK Core) has become one of the most widely-deployed agent runtimes in the TypeScript ecosystem. Its appeal is pragmatic: a single unified API surface covers every major LLM provider, tool calling works consistently across models, streaming is first-class, and the generateText / streamText functions with maxSteps support multi-step agentic loops without requiring framework-level orchestration. For teams building with Next.js, Remix, or any Node.js stack, it's often the first agent runtime they reach for.

That simplicity creates a gap. The maxSteps parameter — Vercel AI SDK's primary safeguard for agentic loops — sets the maximum number of consecutive LLM inference calls before the SDK stops the loop and returns the accumulated result. It answers "how many turns has this agent taken?" It cannot answer "is this agent making progress?" An agent calling the same search tool twelve times with near-identical queries, each time receiving valid-but-unsatisfying results, will exhaust all twelve steps at full cost. A circuit breaker would have tripped at step three and preserved nine steps for a differently-framed attempt.

This post covers four Vercel AI SDK-specific failure modes and shows how to build an AISdkBreaker circuit breaker in TypeScript that intercepts them by wrapping tool execute functions and the onStepFinish callback — without patching the SDK internals.

Vercel AI SDK agentic architecture in brief

Understanding the failure modes requires a clear model of how generateText runs a multi-step agent. When you pass maxSteps: N alongside a set of tools, the SDK enters an agentic loop:

  1. Call the LLM with the current message history.
  2. If the model returns a tool call (or multiple parallel tool calls), resolve and execute each tool's execute function, collect results, append both the tool call(s) and tool result(s) to the message array, and go to step 1.
  3. If the model returns a text response (no tool calls), or if the step count reaches maxSteps, exit the loop and return.

Each pass through the loop is one "step." Each step consumes at minimum one LLM inference (the call that decides whether to use a tool or produce a final answer) plus any tool executions. The SDK exposes an onStepFinish callback that fires after each step with the step index, tool calls made, tool results received, and usage data. There is also a stopWhen option accepting a predicate function that receives the current step result and can signal an early exit.

Parallel tool calls are supported: if the model returns multiple tool call objects in a single response, the SDK executes them concurrently (the default) or sequentially (if experimental_toolCallStreaming or similar options are configured), then appends all results before the next LLM inference. This is important for the second failure mode below — parallel execution is a feature, but it also multiplies per-step token cost when the model enters a spiral.

The message array passed to each successive LLM call grows with each step. Starting at whatever you provide as the initial messages parameter, each step appends an assistant message (containing tool calls) and one or more tool messages (containing results). By step 15, a modest initial 1,500-token prompt with 300-token average tool results has accumulated over 10,000 tokens of context — all of which the LLM processes on every subsequent step.

Why maxSteps is not a circuit breaker

maxSteps is a hard upper bound, not a pattern detector. The distinction matters for three reasons:

  • It counts, doesn't evaluate. A 10-step agent that runs ten completely distinct tool calls — each advancing the task — should use all ten steps. A 10-step agent that calls the same tool ten times with semantically identical arguments should trip at step three. maxSteps cannot distinguish these two cases.
  • It fires too late. When maxSteps is reached, the full budget has already been consumed. A circuit breaker fires mid-run, preserving remaining capacity for a graceful fallback or a retry with different parameters. By the time maxSteps halts the loop, every inference has already been billed.
  • It doesn't account for token cost growth. Two 10-step runs can have wildly different costs if the message array grows at different rates. An agent accumulating 500 tokens per step costs roughly twice as much as one accumulating 250 tokens per step, yet both hit maxSteps at the same point. The limit is blind to the compounding token cost across steps.

The four failure modes below each exploit the gap between "steps counted" and "cost incurred."

Failure mode 1: Tool call invocation spiral

The tool call invocation spiral is the most common production cost failure in AI SDK Core deployments. It occurs when the model receives a valid but inconclusive tool result and responds by calling the same tool again with a slightly modified argument, hoping a different query will produce a better answer. The cycle repeats because each result is genuinely different (the query changed) but semantically equivalent in content (the tool is returning the same class of information the model already has).

A concrete example: an agent researching a company calls a webSearch tool with "Acme Corp revenue 2025", receives a page of summary results, determines they're too high-level, and calls webSearch again with "Acme Corp annual revenue 2025 Q4". Gets similar results. Calls it again with "Acme Corp 2025 earnings". Each call is a different query; each result is a different document. But the agent is not making progress — it's paraphrasing its way through the same semantic space without synthesizing what it already has.

maxSteps doesn't see this as a problem until the step budget is exhausted. Detection requires tracking the sequence of successful tool calls and their argument fingerprints across steps:

function normalizeToolArgs(args: unknown): string {
  const raw = JSON.stringify(args ?? "");
  return raw
    .toLowerCase()
    .replace(/[^a-z0-9\s]/g, " ")
    .split(/\s+/)
    .filter(Boolean)
    .sort()
    .join(" ");
}

function jaccardSimilarity(a: string, b: string): number {
  const setA = new Set(a.split(" "));
  const setB = new Set(b.split(" "));
  const intersection = new Set([...setA].filter(x => setB.has(x)));
  const union = new Set([...setA, ...setB]);
  return union.size === 0 ? 0 : intersection.size / union.size;
}

// Per-tool sliding window of the last N normalized argument strings.
// Trip when pairwise Jaccard among the last 3 calls >= 0.72.
interface ToolCallHistory {
  toolName: string;
  normalizedArgs: string;
  stepIndex: number;
}

function isToolSpiral(
  history: ToolCallHistory[],
  toolName: string,
  windowSize = 3,
  threshold = 0.72
): boolean {
  const recent = history
    .filter(h => h.toolName === toolName)
    .slice(-windowSize);
  if (recent.length < windowSize) return false;
  for (let i = 1; i < recent.length; i++) {
    if (jaccardSimilarity(recent[i - 1].normalizedArgs, recent[i].normalizedArgs) < threshold) {
      return false;
    }
  }
  return true;
}

The 0.72 Jaccard threshold reliably separates paraphrase repetition from legitimate query refinement. "acme corp revenue 2025" and "acme corp annual revenue 2025 q4" will match at 0.72. "acme corp revenue 2025" and "acme corp headcount 2025" will not — different semantic domain. For pagination-style tools (where the agent legitimately calls the same tool with successive page offsets), check that the argument set includes a monotonically increasing page token; if so, skip the spiral check for that call sequence.

The right place to hook this check in Vercel AI SDK is inside each tool's execute function — the SDK calls this directly before passing the result back to the LLM. Wrapping execute lets the circuit breaker throw before the result is generated, avoiding both the tool execution cost and the next LLM inference:

function guardedTool<TArgs, TResult>(
  tool: { execute: (args: TArgs, ctx: ToolExecutionOptions) => Promise<TResult> },
  breaker: AISdkBreaker
): typeof tool {
  return {
    ...tool,
    execute: async (args: TArgs, ctx: ToolExecutionOptions) => {
      breaker.recordToolCall(ctx.toolName ?? "unknown", args, ctx.messages?.length ?? 0);
      breaker.checkOrThrow(); // throws AISdkBreakerError if tripped
      return tool.execute(args, ctx);
    },
  };
}

Failure mode 2: Parallel tool call cost amplification

Vercel AI SDK supports parallel tool calls — when a model responds with multiple tool call objects in a single generation, the SDK executes them concurrently and appends all results before the next inference. This is a genuine performance feature: an agent that needs to fetch five data sources simultaneously can do so in one step rather than five sequential steps. But parallel execution becomes a cost amplifier when the model enters a spiral at the multi-tool level.

The amplification pattern looks like this: an agent needs to cross-reference information from three sources. In step 1, it calls tools A, B, and C in parallel. In step 2, dissatisfied with the combination of results, it calls A′, B′, and C′ — slightly modified versions of the same three queries. In step 3, it calls A″, B″, and C″. Each step generates three tool calls, three tool results, and one LLM inference. The spiral detector for individual tools (failure mode 1) may not fire because no single tool crosses the per-tool repetition threshold — each tool's individual call count is only 3 across 3 steps. But the session is burning 3× the token cost of a non-parallel spiral.

Detecting this requires a session-level view of tool call diversity across steps, not just per-tool history:

interface StepRecord {
  stepIndex: number;
  toolCalls: { toolName: string; normalizedArgs: string }[];
}

function isParallelAmplification(
  steps: StepRecord[],
  minSteps = 3,
  duplicateRatioThreshold = 0.60
): boolean {
  if (steps.length < minSteps) return false;

  const recentSteps = steps.slice(-minSteps);
  const allCallFingerprints: string[] = [];
  for (const step of recentSteps) {
    for (const call of step.toolCalls) {
      allCallFingerprints.push(`${call.toolName}::${call.normalizedArgs}`);
    }
  }

  const uniqueFingerprints = new Set(allCallFingerprints);
  const duplicateRatio = 1 - uniqueFingerprints.size / allCallFingerprints.length;
  return duplicateRatio >= duplicateRatioThreshold;
}

A duplicate ratio of 0.60 means at least 60% of the tool call fingerprints across the last three steps are repetitions. With three tools called per step over three steps (nine total calls), this fires when six or more calls are near-duplicates of previously-seen (tool, args) pairs. That's a strong signal of a parallel spiral. The threshold should be tuned upward (0.70–0.80) for agents that legitimately repeat a subset of lookups as ground-truth anchors across steps.

Failure mode 3: Cross-step context window drift

Context drift is a cost failure that doesn't look like a loop because the agent is making genuine progress — each step calls different tools and incorporates new information. The problem is structural: every step in a Vercel AI SDK agentic loop appends at minimum two messages to the array (the assistant message with tool calls, and the tool message(s) with results). Each subsequent LLM inference processes the entire accumulated array. The per-inference token cost grows linearly with step count, even when the agent is working correctly.

The cost math is worse than it appears. An agent starting with a 2,000-token context and adding 400 tokens per step processes these token counts across ten steps: 2,000 + 2,400 + 2,800 + 3,200 + 3,600 + 4,000 + 4,400 + 4,800 + 5,200 + 5,600. That sums to 38,000 input tokens. The same agent with a stable 2,000-token context throughout those ten steps would consume 20,000 input tokens. The drift multiplier is 1.9× — nearly double the cost for the same number of steps. With verbose tool results (JSON API responses, scraped HTML, code blocks), the per-step growth rate is often 800–1,500 tokens, making the multiplier 3× or higher.

maxSteps doesn't see token cost — it sees step count. An agent hitting maxSteps: 10 with 3× cost drift is burning three times the expected budget while staying within the step limit. Detection requires estimating tokens per step from the message array:

function estimateTokens(content: string): number {
  // ~4 chars per token for English prose + code (rough but consistent)
  return Math.ceil(content.length / 4);
}

function estimateStepTokens(messages: CoreMessage[]): number {
  return messages.reduce((sum, msg) => {
    const content = Array.isArray(msg.content)
      ? msg.content.map((c: ContentPart) =>
          "text" in c ? c.text : JSON.stringify(c)
        ).join("")
      : String(msg.content ?? "");
    return sum + estimateTokens(content);
  }, 0);
}

interface DriftState {
  stepTokenCounts: number[]; // estimated total context tokens per step
}

function isDrifting(state: DriftState, growthThreshold = 1.30): boolean {
  const counts = state.stepTokenCounts;
  if (counts.length < 3) return false;
  const recent = counts.slice(-3);
  return (
    recent[1] / recent[0] >= growthThreshold &&
    recent[2] / recent[1] >= growthThreshold
  );
}

Three consecutive steps each showing 30% or more context growth signals structural drift. This threshold is intentionally conservative — a single large tool result shouldn't trip it. The pattern it catches is compounding growth: the second step grows because the first was large; the third grows because the first and second were large. That compounding curve is what distinguishes legitimate tool result accumulation (which stabilizes as the agent nears its answer) from runaway drift (which accelerates).

The right response to a drift trip in Vercel AI SDK is not to abort — it's to compress. Summarize the tool results accumulated so far into a single synthetic message, replace the accumulated tool call / tool result pairs with that summary, and continue. The SDK's message array is mutable before the next step; you can inject a compressed history before the next generateText internal call via the onStepFinish callback (which receives the step's messages and lets you mutate state before the next step begins).

Failure mode 4: Provider-fallback re-routing loop

Provider-fallback loops emerge in architectures that use multiple language models within a single agentic session — a pattern that has become common as teams optimize for cost-vs-quality tradeoffs. The typical setup: a cheap, fast model handles tool routing and initial research; when the fast model's response quality falls below a threshold, a tool call to a routeToExpert function delegates to a more capable (and expensive) model for synthesis. The expert model, unable to synthesize with the available context, returns a response that the routing model interprets as insufficient — triggering another routeToExpert delegation.

This back-delegation cycle is invisible to both models individually. The routing model believes it is escalating appropriately based on response quality signals. The expert model believes it is being called with legitimate tasks. Neither has a view of the full call chain. maxSteps counts the total steps across both models; it doesn't distinguish how many times any given routing path has been traversed.

Detection requires tracking the sequence of model invocations and delegation targets across the session:

interface DelegationRecord {
  fromModel: string;
  toModel: string;
  stepIndex: number;
  argsHash: string; // hash of the delegation request args
}

function isBackDelegationLoop(
  records: DelegationRecord[],
  windowSize = 4,
  repetitionThreshold = 0.60
): boolean {
  if (records.length < windowSize) return false;

  const recent = records.slice(-windowSize);
  // A loop exists when the same (fromModel, toModel) pair appears in
  // the majority of recent records with similar args
  const pairCounts = new Map<string, number>();
  for (const r of recent) {
    const key = `${r.fromModel}->${r.toModel}`;
    pairCounts.set(key, (pairCounts.get(key) ?? 0) + 1);
  }

  for (const count of pairCounts.values()) {
    if (count / windowSize >= repetitionThreshold) return true;
  }
  return false;
}

When 60% or more of the last four delegation records show the same source-to-destination model pair, the routing has stopped exploring new strategies and is cycling. At that point the breaker should inject a forced-synthesis instruction into the next model call: "You have received the available context. Synthesize the best available answer now, even if incomplete. Do not delegate further."

The AISdkBreaker: putting it together

The four detectors above share state through a single AISdkBreaker instance that is constructed once per agent run and passed into the tool wrappers and step callbacks:

export class AISdkBreakerError extends Error {
  constructor(
    public readonly reason: "tool_spiral" | "parallel_amplification" | "context_drift" | "delegation_loop" | "budget_exceeded",
    message: string
  ) {
    super(message);
    this.name = "AISdkBreakerError";
  }
}

export interface BreakerConfig {
  maxStepBudgetUsd?: number;         // hard cost cap (requires cost-per-token metadata)
  spiralWindowSize?: number;         // default 3
  spiralJaccardThreshold?: number;   // default 0.72
  parallelDuplicateRatio?: number;   // default 0.60
  driftGrowthThreshold?: number;     // default 1.30
  driftMinSteps?: number;            // default 3
  delegationWindowSize?: number;     // default 4
  delegationRepetitionThreshold?: number; // default 0.60
}

export class AISdkBreaker {
  private toolCallHistory: ToolCallHistory[] = [];
  private stepRecords: StepRecord[] = [];
  private driftState: DriftState = { stepTokenCounts: [] };
  private delegationRecords: DelegationRecord[] = [];
  private tripped = false;
  private tripReason: AISdkBreakerError["reason"] | null = null;

  constructor(private readonly config: BreakerConfig = {}) {}

  recordToolCall(toolName: string, args: unknown, contextLength: number): void {
    const normalizedArgs = normalizeToolArgs(args);
    const stepIndex = this.stepRecords.length;
    this.toolCallHistory.push({ toolName, normalizedArgs, stepIndex });

    // Per-tool spiral check
    if (isToolSpiral(
      this.toolCallHistory,
      toolName,
      this.config.spiralWindowSize ?? 3,
      this.config.spiralJaccardThreshold ?? 0.72
    )) {
      this.trip("tool_spiral");
    }
  }

  recordStepFinish(stepIndex: number, toolCalls: { toolName: string; args: unknown }[], messages: CoreMessage[]): void {
    const normalizedCalls = toolCalls.map(tc => ({
      toolName: tc.toolName,
      normalizedArgs: normalizeToolArgs(tc.args),
    }));
    this.stepRecords.push({ stepIndex, toolCalls: normalizedCalls });

    // Parallel amplification check
    if (isParallelAmplification(
      this.stepRecords,
      this.config.driftMinSteps ?? 3,
      this.config.parallelDuplicateRatio ?? 0.60
    )) {
      this.trip("parallel_amplification");
    }

    // Drift check
    const stepTokens = estimateStepTokens(messages);
    this.driftState.stepTokenCounts.push(stepTokens);
    if (isDrifting(this.driftState, this.config.driftGrowthThreshold ?? 1.30)) {
      this.trip("context_drift");
    }
  }

  recordDelegation(fromModel: string, toModel: string, stepIndex: number, args: unknown): void {
    const argsHash = normalizeToolArgs(args).slice(0, 32);
    this.delegationRecords.push({ fromModel, toModel, stepIndex, argsHash });

    if (isBackDelegationLoop(
      this.delegationRecords,
      this.config.delegationWindowSize ?? 4,
      this.config.delegationRepetitionThreshold ?? 0.60
    )) {
      this.trip("delegation_loop");
    }
  }

  private trip(reason: AISdkBreakerError["reason"]): void {
    this.tripped = true;
    this.tripReason = reason;
  }

  checkOrThrow(): void {
    if (this.tripped) {
      throw new AISdkBreakerError(
        this.tripReason!,
        `AISdkBreaker tripped: ${this.tripReason}`
      );
    }
  }

  isTripped(): boolean { return this.tripped; }
  getReason(): AISdkBreakerError["reason"] | null { return this.tripReason; }
}

Wiring the breaker into generateText

Wrap each tool's execute function at call site and pass an onStepFinish callback:

import { generateText, tool, CoreMessage } from "ai";
import { openai } from "@ai-sdk/openai";
import { z } from "zod";

async function runGuardedAgent(userMessage: string): Promise<string> {
  const breaker = new AISdkBreaker({
    spiralJaccardThreshold: 0.72,
    driftGrowthThreshold: 1.30,
  });

  const messages: CoreMessage[] = [{ role: "user", content: userMessage }];

  try {
    const result = await generateText({
      model: openai("gpt-4o"),
      messages,
      maxSteps: 15,
      tools: {
        webSearch: tool({
          description: "Search the web for information",
          parameters: z.object({ query: z.string() }),
          execute: async ({ query }, ctx) => {
            // Record BEFORE executing — breaker can throw here
            breaker.recordToolCall("webSearch", { query }, messages.length);
            breaker.checkOrThrow();
            return performWebSearch(query);
          },
        }),
        readPage: tool({
          description: "Read a specific URL",
          parameters: z.object({ url: z.string() }),
          execute: async ({ url }, ctx) => {
            breaker.recordToolCall("readPage", { url }, messages.length);
            breaker.checkOrThrow();
            return fetchPage(url);
          },
        }),
      },
      onStepFinish: ({ stepType, toolCalls, toolResults, usage, response }) => {
        if (stepType === "tool-result" && toolCalls) {
          breaker.recordStepFinish(
            breaker["stepRecords"].length,
            toolCalls.map(tc => ({ toolName: tc.toolName, args: tc.args })),
            messages
          );
          breaker.checkOrThrow();
        }
      },
    });

    return result.text;
  } catch (err) {
    if (err instanceof AISdkBreakerError) {
      // Log the trip reason and return a graceful fallback
      console.error(`[RunGuard] Agent tripped: ${err.reason}`);
      return `I was unable to complete this research due to a detected loop pattern (${err.reason}). Here is what I found before stopping: [partial result]`;
    }
    throw err;
  }
}

The breaker is instantiated per agent run (not as a singleton). Each run gets a fresh state, so a spiral in one session doesn't affect the next. The checkOrThrow() call inside the execute wrapper fires before the tool executes — this prevents both the tool call cost and the subsequent LLM inference that would have processed the repeated result.

Breaker config reference

Parameter Default When to adjust
spiralWindowSize 3 Raise to 4–5 for agents with legitimate multi-pass query refinement (e.g., faceted search pipelines)
spiralJaccardThreshold 0.72 Lower toward 0.60 if you're seeing missed spirals; raise toward 0.85 if legitimate refinements are false-positiving
parallelDuplicateRatio 0.60 Raise toward 0.75 for agents that legitimately re-anchor on a small set of ground-truth lookups across every step
driftGrowthThreshold 1.30 Raise toward 1.50 for agents that process large structured documents as expected behavior (data extraction pipelines)
driftMinSteps 3 Leave at 3; raising it delays detection without benefit for most workloads
delegationWindowSize 4 Raise toward 6 for legitimate multi-expert orchestration where the same pair of models are validly called in sequence multiple times
delegationRepetitionThreshold 0.60 Raise toward 0.75 for routing architectures that deliberately concentrate work on one expert model

Integrating with RunGuard

The AISdkBreaker above is a standalone implementation you can drop into any Vercel AI SDK project. RunGuard wraps this pattern as a managed service: the same spiral detection, context drift monitoring, and delegation loop tracking are available via a single guard() call, with trip events streamed to your Slack or PagerDuty in real time and a 30-day trip dashboard showing which agents are burning budget and why.

import { guard } from "@runguard/sdk";

// Replace the manual breaker instantiation above with:
const { tools, onStepFinish } = guard({
  agentId: "research-agent",
  config: { spiralJaccardThreshold: 0.72, driftGrowthThreshold: 1.30 },
});

const result = await generateText({
  model: openai("gpt-4o"),
  messages,
  maxSteps: 15,
  tools: {
    webSearch: tools.wrap(webSearchTool),
    readPage: tools.wrap(readPageTool),
  },
  onStepFinish,
});

The tools.wrap() function applies the execute interceptor pattern from this post. The onStepFinish callback handles the step-level checks. Trip events are sent to your RunGuard dashboard automatically — no additional instrumentation required.

FAQ

Does the breaker work with streamText as well as generateText?

Yes. streamText uses the same tool execution model — tool execute functions are called synchronously within each step, and onStepFinish fires after each step completes. The AISdkBreaker wiring shown here works identically with streamText. The only difference is that when the breaker throws inside an execute wrapper during streaming, the stream is terminated and the error is surfaced at the consumer. Handle it with a try/catch around the stream consumption loop or the onError callback if you're using the AI SDK's React hooks (useChat, useCompletion).

Will the spiral detector false-positive on pagination tools that legitimately call the same tool repeatedly?

Pagination is the most common false-positive source. The fix is to exclude pagination arguments from the normalized fingerprint before running the Jaccard similarity check. If your pagination tool accepts a cursor or page parameter, strip it before normalization: const { cursor, page, ...rest } = args; normalizeToolArgs(rest). With the cursor stripped, each paginated call will have the same fingerprint — but that's intentional. You want the spiral check to evaluate the non-pagination arguments to confirm the model isn't requesting the same data with different cursors due to confusion rather than legitimate traversal. Add a monotonicity check as a secondary gate: if the cursor/page value is strictly increasing across the window, skip the spiral check.

How does the breaker interact with Vercel AI SDK's built-in stopWhen option?

stopWhen is evaluated by the SDK after each step and can exit the loop early based on your predicate. The AISdkBreaker and stopWhen are complementary: stopWhen handles business-logic exit conditions (e.g., "stop when the answer includes a citation count above 5"), while the breaker handles anomaly-based exits (loops, drift, delegation cycles). Wire the breaker alongside stopWhen — they don't interfere. If the breaker throws inside an execute wrapper, execution halts before the SDK even evaluates stopWhen for that step.

The context drift check fires on legitimate research agents that process large documents. How do I tune it?

Raise driftGrowthThreshold to 1.50 or 1.60 for agents that are expected to process large documents. Alternatively, replace the three-consecutive-steps compound growth check with a total token budget cap: track cumulative estimated tokens across all steps and trip when the session total exceeds a per-run threshold (e.g., 50,000 tokens for a $0.50 cap at GPT-4o pricing). The budget cap is a coarser signal than the growth rate check but is less sensitive to single-step spikes from large document fetches.

Does this work with AI SDK's experimental useChat / useCompletion React hooks for browser-side agents?

The AISdkBreaker runs server-side — it wraps tool execute functions that execute in the Node.js route handler (Next.js API route, Remix action, or standalone server). The client-side hooks handle streaming and UI state; they don't execute tools directly. If you're using the AI SDK's route handler pattern (streamText in a Next.js /api/chat route with toDataStreamResponse()), wire the breaker into the server-side streamText call. The breaker's throws surface as stream errors that the client hook handles via the onError callback, where you can display a user-facing "I encountered a loop, here's what I found" message.

Stop runaway AI SDK agents before the bill lands

RunGuard wraps the AISdkBreaker pattern above as a managed service — spiral detection, context drift monitoring, and delegation loop tracking with real-time Slack alerts and a 30-day trip dashboard. One npm install away.

Start free 14-day trial →