npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

osamcall

v1.0.1

Published

Official SDK for OsamCall — 1:1 video calling for web, Next.js, Node.js, and React Native.

Downloads

231

Readme

OsamCall

Dedicated 1:1 video calling for your own app — built on the same open-source LiveKit media engine as osamlive, but with an API shaped around calls, not generic rooms: every call is hard-capped at exactly 2 participants by the media server itself, and the React layer hands you a single remoteParticipant instead of a list you'd only ever use one entry of.

If you need group calls, arbitrary-size rooms, or OBS/RTMP live streaming, use osamlive instead — both packages talk to the same backend deployment, so you can use either (or both) against one project.

  • Works everywhere on the server: Node.js, Next.js (API routes, Server Actions, Route Handlers), Express, serverless functions, even Edge runtimes — it's built on nothing but the global fetch.
  • Works in the browser: a React hook, a ready-made full call screen (<DefaultCallUI />) you can drop in as-is or fully replace, and lower-level primitives (<CallView />, <ParticipantView />) if you want your own layout.
  • Works in React Native: osamcall/react-native has its own DefaultCallUI for the zero-effort path and OsamCallRoom/useCallMedia() for a custom UI, plus an Expo config plugin that fixes a real @livekit/react-native Android build issue — see React Native. (osamcall/react, the web UI layer, is browser-only and doesn't run in RN — that's what osamcall/react-native is for.)
  • Self-mirroring local preview, like WhatsApp/FaceTime: your own video preview is flipped so raising your right hand shows up on your right side, the way a mirror works — the other participant always sees the true, un-mirrored feed regardless (only your own local rendering is flipped; nothing about what's actually sent changes).
  • Automatic volume leveling: incoming audio is run through a compressor + makeup gain so it lands at one comfortable level regardless of how loud or quiet the other person's mic/environment is — on top of autoGainControl on what you send. On by default, and can be turned off per component if you'd rather handle audio yourself.

Compatibility

| Environment | Works? | Notes | |---|---|---| | JavaScript (any runtime) | ✅ | No TypeScript required — the server SDK is plain JS/fetch, the shipped .d.ts types are optional. | | TypeScript | ✅ | No minimum version pinned; the shipped types avoid anything recent enough to trip up older compilers. | | Node.js backend | ✅ | 18+ (needs global fetch). This is where OsamCall (the server SDK) runs — see Quick start. | | Next.js | ✅ | Server SDK in API routes/Server Actions/Route Handlers (even Edge runtime); osamcall/react in client components — see Next.js. | | Plain React (Vite, CRA, etc.) | ✅ | osamcall/react has no Next.js dependency at all — see Plain React. | | Vanilla JS/TS, no framework | ✅ | osamcall/react needs React, but the server SDK doesn't, and connecting in the browser is just livekit-client directly — see Vanilla JavaScript. | | Bare React Native | ✅ | osamcall/react-native ships DefaultCallUI for the zero-effort path and OsamCallRoom/useCallMedia() for a custom UI — both hide @livekit/react-native's raw API (no registerGlobals() step, no manual AudioSession handling, no Track.Source filtering). One caveat: automatic volume leveling doesn't carry over (no Web Audio API in RN) — see React Native for the full list of what does/doesn't transfer. | | Expo (managed) | ⚠️ Partial, with a catch | Same as bare RN above, plus: @livekit/react-native-webrtc is a native module, so it does not run in Expo Go. You need EAS Build (or expo prebuild) with a dev client; Expo's managed workflow supports this via config plugins, it's just not the Expo-Go-only path. |

The one feature that doesn't carry over to React Native is automatic volume leveling — it's built on the browser's Web Audio API, which has no equivalent in RN's JS runtime. Default UI and self-mirroring preview both work natively on RN — see React Native for details.

Install

npm install osamcall

If you're building the call UI in a browser (React/Next.js), also install LiveKit's client SDK, which this package's React helpers wrap:

npm install livekit-client

Building for React Native instead? See React Native for its own install step (a few more packages, since RN needs native WebRTC modules).

The two (or three) halves of this SDK

| | Where it runs | What it needs | Import | |---|---|---|---| | Server SDK | Your backend only | Your project's App Secret | import OsamCall from "osamcall" | | React helpers | Browser / client component | A { token, wsUrl } pair from your backend | import { useOsamCall, CallView } from "osamcall/react" | | React Native helpers | Your RN/Expo app | Same { token, wsUrl } pair | import { DefaultCallUI } from "osamcall/react-native" |

Never put your App Secret in browser or mobile app code. The flow is always:

Caller's client   →  your backend  →  osamcall server SDK  →  OsamCall
      ▲                                                            │
      └────────────────── { callCode, token, wsUrl } ─────────────┘

Callee's client   →  your backend  →  osamcall server SDK  →  OsamCall
      ▲                                    (joinCall with the same callCode)
      └────────────────── { token, wsUrl } ───────────────────────┘

Your backend mints a fresh, short-lived token per real user right before they join. Never share one token across users, and never mint tokens ahead of time and store them.

Getting your credentials

Same App Key as osamlive — they're the same project/dashboard, just a different API surface on it. If you already have an OSAMLIVE_APP_KEY, reuse it here.

# .env (server-side only — never NEXT_PUBLIC_/EXPO_PUBLIC_/etc.)
OSAMCALL_APP_KEY=proj_xxxxxxxxxxxxxxxx.yyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyyy
OSAMCALL_BASE_URL=https://live.yourdomain.com

Quick start — Node.js

import { OsamCall } from "osamcall";

const client = new OsamCall({
  appKey: process.env.OSAMCALL_APP_KEY,
  baseUrl: process.env.OSAMCALL_BASE_URL,
});

// Caller starts the call:
const caller = await client.startCall({ identity: currentUser.id, name: currentUser.name });
// caller = { callCode, token, wsUrl, iceServers? }
// Send caller.callCode to the callee (push notification, your own signaling, etc.)
// and { token, wsUrl, iceServers } to the caller's own client to join.

// Callee joins using the call code:
const callee = await client.joinCall({ callCode: caller.callCode, identity: otherUser.id });
// Send { token, wsUrl, iceServers } to the callee's client.

Next.js

API routes (app/api/call/start/route.ts and app/api/call/join/route.ts) — this is where the App Key actually lives:

// app/api/call/start/route.ts
import { NextResponse } from "next/server";
import { OsamCall } from "osamcall";
import { getCurrentUser } from "@/lib/auth";

const client = new OsamCall({
  appKey: process.env.OSAMCALL_APP_KEY!,
  baseUrl: process.env.OSAMCALL_BASE_URL!,
});

export async function POST(req: Request) {
  const user = await getCurrentUser(req);
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const call = await client.startCall({ identity: user.id, name: user.name });
  return NextResponse.json(call);
}
// app/api/call/join/route.ts
export async function POST(req: Request) {
  const user = await getCurrentUser(req);
  if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

  const { callCode } = await req.json();
  try {
    const call = await client.joinCall({ callCode, identity: user.id, name: user.name });
    return NextResponse.json(call);
  } catch (err: any) {
    // err.status is 409 if the call already has two other participants
    return NextResponse.json({ error: err.message }, { status: err.status ?? 500 });
  }
}

Client component — default call UI (the fastest way to get a working call screen):

<DefaultCallUI /> connects automatically, shows the mirrored local preview + the other participant's video with auto-leveled audio, and renders mic/camera/end-call controls — the whole call experience in one component:

"use client";

import { useEffect, useState } from "react";
import { DefaultCallUI } from "osamcall/react";

export default function CallScreen({ callCode, isCaller }: { callCode?: string; isCaller: boolean }) {
  const [creds, setCreds] = useState<{ token: string; wsUrl: string; iceServers?: any[]; callCode: string } | null>(null);

  useEffect(() => {
    fetch(isCaller ? "/api/call/start" : "/api/call/join", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ callCode }),
    })
      .then((res) => res.json())
      .then(setCreds);
  }, []);

  if (!creds) return <p>Connecting…</p>;

  return (
    <div style={{ height: "100vh" }}>
      {isCaller && <p>Share this code: {creds.callCode}</p>}
      <DefaultCallUI
        token={creds.token}
        wsUrl={creds.wsUrl}
        iceServers={creds.iceServers}
        onEndCall={() => {
          /* e.g. router.push("/") */
        }}
        style={{ height: "calc(100vh - 32px)" }}
      />
    </div>
  );
}

Building your own UI instead — DefaultCallUI is just useOsamCall + CallView + some buttons, all exported below, so you're never stuck with the default look. Skip DefaultCallUI and use the hook directly for full control over layout and controls:

"use client";

import { useEffect, useState } from "react";
import { useOsamCall, CallView } from "osamcall/react";

export default function CustomCallScreen({ callCode, isCaller }: { callCode?: string; isCaller: boolean }) {
  const { connect, disconnect, isConnected, localParticipant, remoteParticipant, toggleCamera, toggleMicrophone } =
    useOsamCall();
  const [error, setError] = useState<string | null>(null);
  const [code, setCode] = useState(callCode);

  useEffect(() => {
    (async () => {
      const res = await fetch(isCaller ? "/api/call/start" : "/api/call/join", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ callCode }),
      });
      const data = await res.json();
      if (!res.ok) return setError(data.error);
      setCode(data.callCode);
      await connect({ token: data.token, wsUrl: data.wsUrl, iceServers: data.iceServers }).catch((err) =>
        setError(err.message)
      );
    })();
    return () => disconnect();
  }, []);

  if (error) return <p>Could not join call: {error}</p>;

  return (
    <div style={{ height: "100vh" }}>
      {isCaller && code && <p>Share this code: {code}</p>}
      <CallView
        localParticipant={localParticipant}
        remoteParticipant={remoteParticipant}
        style={{ height: "80vh", borderRadius: 16, overflow: "hidden" }}
      />
      {isConnected && (
        <div style={{ marginTop: 12, display: "flex", gap: 8 }}>
          <button onClick={toggleCamera}>Toggle camera</button>
          <button onClick={toggleMicrophone}>Toggle mic</button>
          <button onClick={disconnect}>Leave call</button>
        </div>
      )}
    </div>
  );
}

Plain React (Vite, CRA, etc.)

Same as the Next.js client component above — osamcall/react doesn't depend on Next.js at all.

Vanilla JavaScript / no framework

osamcall/react needs React, but nothing about connecting to a call actually requires it — that hook is a thin wrapper over livekit-client, which you can use directly. Auto-leveled audio and the mirrored preview are conveniences this package's React layer adds on top; without React you wire livekit-client up yourself (mirroring is one CSS line, auto-leveling is the same Web Audio graph shown in this package's source if you want it — ask if you'd like a plain-JS helper for that extracted out separately).

<video id="remote-video" autoplay playsinline></video>
<video id="local-video" autoplay playsinline muted style="transform: scaleX(-1)"></video>
<!-- scaleX(-1) above is the whole "mirror" trick — flips only your own local preview -->

<script type="module">
  import { Room, RoomEvent, Track } from "https://esm.sh/livekit-client@2";

  // { token, wsUrl, iceServers } comes from YOUR backend, which called the osamcall
  // server SDK's startCall()/joinCall() — never call those from the browser.
  const res = await fetch("/api/call/join", { method: "POST", body: JSON.stringify({ callCode }) });
  const { token, wsUrl, iceServers } = await res.json();

  const room = new Room();

  room.on(RoomEvent.TrackSubscribed, (track) => {
    if (track.kind === Track.Kind.Video) track.attach(document.getElementById("remote-video"));
    if (track.kind === Track.Kind.Audio) track.attach(); // creates + auto-plays an <audio> element
  });

  await room.connect(wsUrl, token, iceServers?.length ? { rtcConfig: { iceServers } } : undefined);
  await room.localParticipant.setCameraEnabled(true);
  await room.localParticipant.setMicrophoneEnabled(true, {
    autoGainControl: true,
    echoCancellation: true,
    noiseSuppression: true,
  });

  const camTrack = room.localParticipant.getTrackPublication(Track.Source.Camera)?.track;
  camTrack?.attach(document.getElementById("local-video"));
</script>

Multiple concurrent calls

Yes — this is inherent to the architecture, not something you opt into. Every call is its own LiveKit room (call-<callCode>), and callCode is randomly generated per call (startCall), so any number of independent 1:1 calls can be in progress at the same time, fully isolated from each other — Alice↔Bob and Carol↔Dave never see or affect one another, whether they're started by the same backend process or different ones. This was verified directly: two calls started simultaneously (Promise.all), each joined by its own pair of participants, with one call ended mid-flight — the other was completely unaffected and remained joinable.

The only real ceiling is your deployment's compute/bandwidth (each call's media flows through your self-hosted LiveKit server), not anything in this SDK or the call-code scheme itself — LiveKit's SFU handles many simultaneous rooms as standard operation.

React Native

osamcall/react (the web hook, DefaultCallUI, CallView, ParticipantView) is built on browser DOM APIs (<video>/<audio> elements, AudioContext) and does not run in React Native — don't import it there. For React Native, use osamcall/react-native instead — a separate entry point with its own DefaultCallUI and CallView, built on LiveKit's own official React Native SDK, @livekit/react-native.

The server SDK (osamcall, the default export) is plain fetch-based and runs fine inside a React Native app's JS engine too, but should still only ever run on your backend, never inside the app bundle (a compiled mobile app isn't a secure place for a secret either — it can be decompiled).

Setup

npm install osamcall @livekit/react-native @livekit/react-native-webrtc livekit-client
npx pod-install # iOS

Expo: add "osamcall" to your app.json's plugins array. That's it — no manual registerGlobals() call (this package does it for you, automatically, on import), and this plugin's real job is a Gradle/Kotlin fix: @livekit/react-native's Android build can fail with Module was compiled with an incompatible version of Kotlin on any project that overrides its Kotlin version for any reason (a genuinely painful one to debug blind — this plugin ships the actual working fix, extracted from a real production app that hit it, instead of you needing to patch-package it yourself):

{ "expo": { "plugins": ["osamcall"] } }

If that alone doesn't clear it, also pass your project's exact Kotlin version — this adds a second, more targeted fix directly inside @livekit/react-native's own build.gradle:

{ "expo": { "plugins": [["osamcall", { "kotlinVersion": "2.3.0" }]] } }

(Already have native android/ios folders generated locally? Run npx expo prebuild --clean once after adding this plugin to pick up the fix — a fresh expo run:android/EAS Build from a clean checkout picks it up automatically.)

Bare React Native (no Expo): registerGlobals() is still automatic. The Kotlin fix above is Expo-plugin-specific — apply the equivalent Gradle change by hand if you hit the same error (see osamcall's app.plugin.cjs source for exactly what it does).

Default call UI

import { useEffect, useState } from "react";
import { View, Text } from "react-native";
import { DefaultCallUI } from "osamcall/react-native";

export function CallScreen({ callCode, isCaller, onLeave }: { callCode?: string; isCaller: boolean; onLeave: () => void }) {
  const [creds, setCreds] = useState<{ token: string; wsUrl: string; iceServers?: any[]; callCode: string } | null>(null);

  useEffect(() => {
    fetch(isCaller ? "https://your-backend.com/api/call/start" : "https://your-backend.com/api/call/join", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ callCode }),
    })
      .then((res) => res.json())
      .then(setCreds);
  }, []);

  if (!creds) return <View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}><Text>Connecting…</Text></View>;

  return (
    <DefaultCallUI
      token={creds.token}
      wsUrl={creds.wsUrl}
      iceServers={creds.iceServers}
      onEndCall={onLeave}
      style={{ flex: 1 }}
    />
  );
}

Same shape as the web /api/call/start and /api/call/join routes shown earlier — one backend serves both your web and native clients.

Building your own UI instead — this is the realistic path for any real app (custom layout, gifting/billing UI, draggable picture-in-picture, whatever your call screen actually needs), not an edge case — so it's built to need zero LiveKit-specific knowledge, not just a thin re-export of it. Use OsamCallRoom (instead of raw LiveKitRoom — same props, plus it manages the Android/iOS audio session for you automatically, a real, silent bug otherwise: calls come through at near-silent volume on Android without it) and useCallMedia() (instead of useTracks(...) + Track.Source.Camera + manually filtering by participant.isLocal yourself):

import { OsamCallRoom, useCallMedia, VideoTrack } from "osamcall/react-native";
import { StyleSheet, View, Button } from "react-native";

function MyCallScreen({ token, wsUrl, iceServers }) {
  return (
    <OsamCallRoom serverUrl={wsUrl} token={token} connect audio video iceServers={iceServers}>
      <MyCallUI />
    </OsamCallRoom>
  );
}

function MyCallUI() {
  const { remoteVideoTrack, localVideoTrack, toggleCamera, toggleMicrophone } = useCallMedia();
  return (
    <View style={{ flex: 1 }}>
      <VideoTrack trackRef={remoteVideoTrack} style={StyleSheet.absoluteFill} objectFit="cover" />
      <VideoTrack trackRef={localVideoTrack} mirror style={{ position: "absolute", top: 40, right: 16, width: 100, height: 140 }} />
      <Button title="Mute" onPress={toggleMicrophone} />
      <Button title="Camera" onPress={toggleCamera} />
    </View>
  );
}

No livekit-client import, no Track.Source enum, no manual track filtering, no AudioSession call — the raw LiveKitRoom/useLocalParticipant/useRemoteParticipants/ useTracks/Track are still re-exported from osamcall/react-native too, for the rare case you need something useCallMedia() doesn't expose, but the common custom-UI case never needs them.

What doesn't carry over from the web package

  • Mirroring works the same way — @livekit/react-native's video view has a native mirror prop, same effect as the web CSS flip, already wired into DefaultCallUI/CallView above.
  • Automatic volume leveling does not carry over. It's built on the browser's Web Audio API (AudioContext/DynamicsCompressorNode), which doesn't exist in React Native's JS runtime, and @livekit/react-native-webrtc doesn't expose an equivalent — a real receive-side audio-leveling feature here would need a separate native audio module, out of scope for this package today. Sender-side autoGainControl (the mic capture constraint) is still applied, same as on web, via OsamCallRoom.

Expo

@livekit/react-native-webrtc is a native module, so this needs a dev client build (EAS Build or expo prebuild) — it will not run inside Expo Go. Expo's managed workflow still works via its config plugin system; it's the "install a native module" path, not the "everything stays in Expo Go" path.

API reference

new OsamCall(options)

| Option | Type | Required | Description | |---|---|---|---| | appKey | string | one of appKey or appId+secret | <appId>.<secret>, shown once at project creation | | appId | string | | Alternative to appKey | | secret | string | | Alternative to appKey | | baseUrl | string | recommended | Your OsamCall/OsamLive deployment's URL |

client.startCall({ identity, name? })

→ Promise<{ callCode, token, wsUrl, iceServers? }>. Creates a new call and mints the caller's own join token in one step. Share callCode with the other participant.

client.joinCall({ callCode, identity, name? })

→ Promise<{ callCode, token, wsUrl, iceServers? }>. Mints a join token for the second participant (or a reconnecting one, using the same identity). Throws OsamCallError with status: 409 if the call already has two other participants.

client.endCall(callCode)

→ Promise<void>. Ends the call immediately, disconnecting both participants.

Errors

Every method throws OsamCallError ({ name: "OsamCallError", message, status }) on failure — wrap calls in try/catch and read .status (e.g. 409 for a full call) to branch on the reason.

osamcall/react

  • <DefaultCallUI token wsUrl iceServers? onEndCall? autoLevelAudio? className? style? /> — the complete default call screen: connects on mount, disconnects on unmount, renders CallView plus mic/camera/end-call controls, shows connecting/error states. The quickest way to a working call; swap it out entirely for your own UI whenever you want.
  • useOsamCall() → { room, isConnected, isConnecting, error, localParticipant, remoteParticipant, connect, disconnect, toggleCamera, toggleMicrophone, isCameraEnabled, isMicrophoneEnabled }. remoteParticipant is null until the other person joins. What DefaultCallUI is built on — use this directly for a fully custom UI.
  • <CallView localParticipant remoteParticipant autoLevelAudio? className? style? /> — ready-made 1:1 layout: remote participant fills the frame (audio auto-leveled by default), local participant is a small mirrored corner picture-in-picture.
  • <ParticipantView participant={p} mirror? autoLevelAudio? className? style? /> — the lower-level primitive CallView is built on, for full control over your own layout. mirror: flip this participant's video for selfie-style local preview — set it only on the local participant, never the remote one (it's a local rendering flip only; the other participant is never affected by it). autoLevelAudio (default true): run this participant's incoming audio through a compressor + makeup gain so it lands at a consistent, comfortable volume; has no effect on the local participant.

osamcall/react-native

  • <DefaultCallUI token wsUrl iceServers? onEndCall? style? /> — the complete default call screen: connects on mount (via OsamCallRoom below), shows the mirrored local preview + remote video, renders mic/camera/end-call controls, shows connecting/error states. No autoLevelAudio prop here — see What doesn't carry over.
  • <OsamCallRoom {...LiveKitRoomProps} iceServers? /> — use this instead of raw LiveKitRoom when building a custom call screen. Same props, plus: manages AudioSession.startAudioSession()/stopAudioSession() automatically (fixes real, silent near-silent-audio-on-Android behavior if you don't), and a flat iceServers prop instead of needing to know connectOptions.rtcConfig.iceServers.
  • useCallMedia() → { localParticipant, remoteParticipant, localVideoTrack, remoteVideoTrack, isCameraEnabled, isMicrophoneEnabled, toggleCamera, toggleMicrophone }. Must be used inside an OsamCallRoom/LiveKitRoom. localVideoTrack/remoteVideoTrack are ready to pass straight to <VideoTrack trackRef={...} /> — no Track.Source enum, no manual filtering by participant.isLocal needed.
  • <CallView style? /> — ready-made 1:1 layout built on useCallMedia(), must be rendered inside an OsamCallRoom/LiveKitRoom.
  • Also re-exports LiveKitRoom, useLocalParticipant, useRemoteParticipants, useTracks, useRoomContext, VideoTrack, AudioSession, Track straight from @livekit/react-native/livekit-client — an escape hatch for the rare case OsamCallRoom/useCallMedia() don't cover, not what you're expected to reach for.

The Expo config plugin (app.plugin.cjs)

Add "osamcall" to your app.json's plugins array (see Setup above for the kotlinVersion option). It:

  • Sets NSCameraUsageDescription/NSMicrophoneUsageDescription on iOS and CAMERA/RECORD_AUDIO/MODIFY_AUDIO_SETTINGS on Android, if not already set.
  • Applies -Xskip-metadata-version-check project-wide — Kotlin's own documented escape hatch for Module was compiled with an incompatible version of Kotlin, which @livekit/react-native (and often several other native modules at once) can trigger on any project that overrides its Kotlin version. Safe unconditionally — it only loosens a compile-time metadata check, not what compiles successfully or any runtime behavior.
  • With kotlinVersion passed: additionally rewrites the Kotlin-version resolution inside @livekit/react-native's own build.gradle directly — a more targeted fix for the cases the flag above doesn't fully resolve on its own.

License

MIT