Skip to content
Blog

How to connect and use Ramus from OpenCode

Connect OpenCode to Ramus over MCP or the ramus CLI, then run, check and share your Android app on a cloud emulator.

OpenCodePublished

On this page

This guide is for developers who use OpenCode and want it to check its own Android work. By the end, OpenCode is connected to Ramus, your APK runs on a cloud Android device, OpenCode has walked through a sign-in flow on it, and you have a link that lets you or a reviewer watch the device, and a device page where you can take over.

What you need

  • OpenCode 2. This guide follows OpenCode 2, which the @opencode/cli package, curl -fsSL https://opencode.ai/v2/install | bash and Homebrew's opencode formula install. opencode --version tells you which one you have. OpenCode 1 differs in a few places; see On OpenCode 1.
  • A Ramus account. Sign in at ramus.dev with GitHub. Without one, ramus trial gives the CLI a 15-minute sandbox key, but a trial cannot sign in over MCP.
  • An APK that runs on an x86_64 emulator, built with your project's own tooling, such as ./gradlew assembleDebug.
  • Node.js 22 or later, if you also want the ramus CLI.

Connect Ramus

Ramus is a remote MCP server at https://api.ramus.dev/api/mcp. It speaks Streamable HTTP, which OpenCode calls "type": "remote".

Add Ramus to your global config, so it is there in every project and no key sits in a file:

~/.config/opencode/opencode.json
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "servers": {
      "ramus": {
        "type": "remote",
        "url": "https://api.ramus.dev/api/mcp",
        "codemode": false
      }
    }
  }
}

codemode: false gives OpenCode the android_* tools directly. By default, OpenCode 2 exposes MCP tools through Code Mode, where the agent calls them from a script inside its execute tool. Ramus works one device call at a time: read the screen, act, read it again. Direct tools keep each of those steps a separate tool call with its own result.

You can also add the server from the command line:

bash
opencode mcp add ramus --global --url https://api.ramus.dev/api/mcp

Without --global, it writes to the project's opencode.json. The command has no flag for codemode, so add that line by hand.

Now sign in:

opencode mcp
$ opencode mcp list
⚠ ramus  needs authentication
$ opencode mcp auth ramus
┌  Authenticate an MCP server
│
◇  Authorization started
│
●  Authorize ramus in your browser. This window will close automatically.
│
●  https://api.ramus.dev/api/auth/oauth2/authorize?response_type=code&client_id=https%3A%2F%2Fopencode.ai%2Foauth%2Fopencode%2Fclient.json&…
│
◇  Waiting for authorization...
◆  Connected to ramus
│
└  Done
$ opencode mcp list
✓ ramus  connected

opencode mcp auth ramus opens your browser on the Ramus consent page, which asks you to connect opencode to Ramus. Choose Allow. Inside OpenCode, /mcps does the same: select ramus and sign in.

The Ramus consent page: Connect opencode to Ramus, published by opencode.ai, returning to 127.0.0.1, listing what it will be able to do, with Deny and AllowThe Ramus consent page: Connect opencode to Ramus, published by opencode.ai, returning to 127.0.0.1, listing what it will be able to do, with Deny and Allow
OpenCode signs in with its published client metadata and a callback on your own machine.

OpenCode identifies itself with its client metadata document, https://opencode.ai/oauth/opencode/client.json, and receives the redirect on http://127.0.0.1:<port>/callback. Ramus supports both, so there is nothing to register. OpenCode stores the credential outside your project config and refreshes it.

Check the connection on the Agents page: Connected lists opencode as a sign-in, with its last call. Disconnect there revokes it, and opencode mcp logout ramus removes the credential from OpenCode. The OpenCode desktop app runs on the same background service as the terminal, so it sees the same servers.

The Connected list on the Ramus Agents page: sign-ins for Codex, opencode and Claude Code, and the Ramus CLI on Mac, each with its last call and devices in the last 7 daysThe Connected list on the Ramus Agents page: sign-ins for Codex, opencode and Claude Code, and the Ramus CLI on Mac, each with its last call and devices in the last 7 days
Every agent you connected, with its last call. Disconnect, Sign out or Revoke cuts it off.

Use an API key instead

Use a key where no browser can open, such as CI or a remote machine. On the Agents page, open the API key tab, name the key and choose Create key. Export it in your shell profile, never in a committed file:

bash
export RAMUS_API_KEY=rms_your_key_here

Turn OAuth off for the server and send the key as a header. OpenCode replaces {env:NAME} with the variable's value; it does not expand $NAME:

~/.config/opencode/opencode.json
{
  "$schema": "https://opencode.ai/config.json",
  "mcp": {
    "servers": {
      "ramus": {
        "type": "remote",
        "url": "https://api.ramus.dev/api/mcp",
        "codemode": false,
        "oauth": false,
        "headers": {
          "Authorization": "Bearer {env:RAMUS_API_KEY}"
        }
      }
    }
  }
}

From the command line, quote the header so your shell writes it as is:

bash
opencode mcp add ramus --global --url https://api.ramus.dev/api/mcp \
  --header 'Authorization=Bearer {env:RAMUS_API_KEY}'

Permissions

OpenCode 2 starts every agent with a policy that allows all actions except reading .env files, so out of the box it calls Ramus without asking. Many setups add rules that ask first, such as { "action": "shell", "resource": "*", "effect": "ask" }. Rules run in order and the last match wins, so put rules for Ramus after those:

~/.config/opencode/opencode.json
{
  "permissions": [
    { "action": "ramus_*", "resource": "*", "effect": "allow" },
    { "action": "shell", "resource": "ramus *", "effect": "allow" }
  ]
}

The first rule covers every Ramus tool, which OpenCode names ramus_<tool>, such as ramus_android_start_session. The second lets the shell tool run ramus commands. A rule with "effect": "deny" hides a tool instead, for example ramus_android_camera_feed. In OpenCode 2 the shell action is shell, not bash. When a rule asks, the prompt offers once, always (saved for the project) and reject. For a one-off run, opencode run --auto approves everything that is not explicitly denied.

The CLI and the skill

OpenCode has a shell, so it can also drive Ramus with the ramus CLI. The CLI uploads a local APK in one command and adds what MCP cannot: an adb tunnel (ramus adb), the hot-reload dev loop (ramus dev) and APK downloads (ramus apps download). Install it and sign in once, in your own terminal:

bash
npm i -g ramus-cli
ramus login

ramus login opens your browser. Check that the page names your machine and choose Allow. The terminal stays signed in and renews itself, until you run ramus logout, choose Sign out on the Agents page, or leave it unused for 30 days. Over SSH it gives you a code to approve on any device. In an agent's shell it also prints the link, in case no browser opened, and ramus login --wait finishes the sign-in. RAMUS_API_KEY in the environment wins over the sign-in.

The Ramus page Sign in the Ramus CLI on Mac, listing what the terminal will be able to do and how long it stays signed in, with Deny and AllowThe Ramus page Sign in the Ramus CLI on Mac, listing what the terminal will be able to do and how long it stays signed in, with Deny and Allow
Check that it names your machine and that you just ran ramus login, then choose Allow.

Then add the android-use skill, which teaches OpenCode the observe, act, verify loop:

bash
mkdir -p .opencode/skills/android-use
curl -fsSL https://ramus.dev/skill.md -o .opencode/skills/android-use/SKILL.md

OpenCode also reads skills from .agents/skills/ and .claude/skills/, and from ~/.config/opencode/skills/ for all projects. It loads a skill when a task matches its description.

Last, tell OpenCode where Ramus fits. Add one line to AGENTS.md at the repository root:

AGENTS.md
Test Android changes on Ramus: https://ramus.dev/agents.md

OpenCode 2 reads AGENTS.md only; it does not fall back to CLAUDE.md.

On OpenCode 1

OpenCode 1 (opencode-ai on npm) is still documented at opencode.ai/docs. Its differences for Ramus:

  • Servers sit directly under mcp: "mcp": { "ramus": { "type": "remote", "url": "https://api.ramus.dev/api/mcp" } }. OpenCode 2 reads this shape too.
  • A tool call times out after a minute unless you set timeout on the server. That covers the 50 seconds android_start_session waits for a device.
  • Sign-in registers a client and uses the redirect http://127.0.0.1:19876/mcp/oauth/callback, which Ramus accepts. opencode mcp auth ramus works the same way.
  • Permissions are keyed by tool, "permission": { "bash": { "ramus *": "allow" } }, and most actions are allowed by default.
  • Without an AGENTS.md, it reads CLAUDE.md.

Start a device

Ask for a device first, before your own app is in the picture:

YoutoOpenCode

Start a Ramus Android device with the demo app, send me a link to watch it right away, then tell me what is on the first screen.

Over MCP, OpenCode calls android_start_session with demo: true. Ramus returns the session id, a screenshot of the device and a watch link, which the agent passes on to you. OpenCode passes the session id to every later call.

With the CLI and the skill, the same step is one command, ramus session start --demo --wait. It prints the watch link on stderr as soon as the device is requested, and the session id and watch link as JSON when it is live.

Open the watch link. You see the device live while OpenCode works; it is view-only. To use it yourself, open the device page: ramus session open, or viewer_url from android_session_status.

Install your app

Ask OpenCode to build and install your APK:

YoutoOpenCode

Build the debug APK with ./gradlew assembleDebug and install it on the Ramus device you started. Tell me when the app is open.

With the CLI, OpenCode installs the build on the running device:

bash
ramus session install --session <id> app/build/outputs/apk/debug/app-debug.apk

ramus session start --apk <file> --wait starts a new device with the APK instead. Prefer session install while a device is running: the device keeps running and everyone watching stays connected.

Over MCP, a local APK takes two steps:

  1. android_upload_apk with the file name returns an apk_uri and a curl command, valid for 15 minutes.
  2. OpenCode runs the curl command with the APK path in its shell, then calls android_install_app with the session id and the apk_uri.
android_upload_apk
{
  "apk_uri": "s3://…/uploads/apk/a67766fa-ca73-459a-a1f1-0b25ac907259/app-debug.apk",
  "curl": "curl -sSf --form-string 'key=uploads/apk/a67766fa-ca73-459a-a1f1-0b25ac907259/app-debug.apk' --form-string 'Content-Type=application/vnd.android.package-archive' … -F 'file=@<APK_PATH>;type=application/vnd.android.package-archive' 'https://…/'",
  "max_mb": 300,
  "expires_in_minutes": 15,
  "next": "replace <APK_PATH> with the APK file path and run curl (no output means success), then call android_start_session or android_install_app with apk_uri"
}

The rules above do not cover curl, so if your setup asks before shell commands, you approve the upload. To run an app Ramus already has without uploading it again, pass its package name: android_start_session with app, or ramus session start --app <package>.

Let the agent check a flow

Now give OpenCode a real check to run:

YoutoOpenCode

On the Ramus device, check the sign-in flow: enter test@example.com and the test password from our fixtures, tap Sign in, and confirm the Welcome screen appears. If it fails, capture logcat and a screenshot, fix the code, rebuild, and install the new APK on the same device. Report what you saw and repeat the watch link.

OpenCode works in a loop: read the screen, act on what it read, verify. A snapshot returns elements with a ref and a gen for that observation. A tap takes the element's text, or the ref and the gen, and expect makes it fail when the expected text does not appear. Each action returns the new screen with fresh refs, so OpenCode decides the next step from that result.

Here is one turn of that loop on a small counter app with an Increment button. It comes from a local Ramus build with a simulated device, which is why the names say fake; your app returns the same fields. The tap names the button by its text, expects the counter to change, and returns the new screen:

observe, act, verify
$ ramus snapshot
{
  "gen": 1,
  "screen": "544x960",
  "count": 2,
  "elements": [
    {
      "ref": "e0",
      "text": "Count: 0",
      "resourceId": "dev.sandlot.fake:id/counter",
      "class": "TextView",
      "x": 0.132,
      "y": 0.417,
      "w": 0.735,
      "h": 0.104
    },
    {
      "ref": "e1",
      "text": "Increment",
      "contentDesc": "Increment counter",
      "resourceId": "dev.sandlot.fake:id/increment",
      "class": "Button",
      "clickable": true,
      "x": 0.132,
      "y": 0.625,
      "w": 0.735,
      "h": 0.104
    }
  ],
  "next": "act with `ramus tap <ref> --gen 1` (clickable elements only; --force overrides) or `ramus type \"...\" --ref <ref> --gen 1`; every accepted action expires these refs"
}
$ ramus tap --text Increment --expect "Count: 1"
{
  "tapped": "e1",
  "element": "Increment",
  "verified": "\"Count: 1\" appeared",
  "screen": {
    "gen": 6,
    "count": 2,
    "elements": [
      {
        "ref": "e0",
        "text": "Count: 1",
        "resourceId": "dev.sandlot.fake:id/counter",
        "class": "TextView",
        "x": 0.132,
        "y": 0.417,
        "w": 0.735,
        "h": 0.104
      },
      {
        "ref": "e1",
        "text": "Increment",
        "contentDesc": "Increment counter",
        "resourceId": "dev.sandlot.fake:id/increment",
        "class": "Button",
        "clickable": true,
        "x": 0.132,
        "y": 0.625,
        "w": 0.735,
        "h": 0.104
      }
    ],
    "foreground_app": "dev.ramus.fake"
  },
  "next": "refs above are current (gen 6); act on them directly"
}

On a sign-in screen the same loop types into the email field with ramus type "test@example.com" --ref <ref> --gen <gen>, which reads the value back and reports verified, then taps Sign in with --expect "Welcome". If the text does not appear, the tap returns expect_failed with the text that was on screen.

Over MCP the loop is android_snapshot, android_type, android_tap with expect, and android_wait_for after navigation. Call one device tool at a time; a tap and the snapshot that reads its result are two steps.

When a check fails, OpenCode collects evidence before changing code: android_logcat (or ramus logcat --limit 200 --filter AndroidRuntime) and android_screenshot (or ramus screenshot --out sign-in.jpg). Then it fixes the code, rebuilds and installs on the same device, and runs the check again. If the app leaves the foreground, android_app_launch or ramus app launch brings it back.

  • For you. Every start returns a watch link (watch_url over MCP, watchUrl from the CLI). Anyone with it can watch the device in a browser without an account, and nothing more, you + 3 people at once. It does not show your name. Ask OpenCode to give it to you right away.
  • For a reviewer. Ask for a separate link: android_share_session with ttl_minutes, or ramus session share --ttl 7d. It returns a new link (share_url over MCP, shareUrl from the CLI) and leaves the others working. Links last 72 hours by default, from 60 seconds up to 7 days. Add --permission watch (permission: "watch" over MCP) for a link that can only watch, and --label for a note the reviewer sees.
  • Revoke. android_share_session with revoke: true, or ramus session share --revoke, cuts off every link for the session, including the watch link.
  • End. android_end_session or ramus session end frees the device. Ending does not revoke links: until a link expires, opening it starts the app again on a fresh device.
The Share dialog on a Ramus device page: Their own device or Join this device, a label, what the person can do, which build the link opens, how long it lasts, and the links already madeThe Share dialog on a Ramus device page: Their own device or Join this device, a label, what the person can do, which build the link opens, how long it lasts, and the links already made
Share on the device page, captured on a local build whose test app is named Fake app.

Share links grant control, so send each one only to the person it is for.

Give each person their own device

The links above join the device OpenCode is on. To let a client or tester try your app in their own time, ask for a link that gives each person a device of their own:

YoutoOpenCode

Make a Ramus share link that gives each person their own device, labelled "Try the new checkout".

OpenCode calls android_share_session with own_device: true and label, or runs ramus session share --own --label "Try the new checkout". Each person who opens the link gets a fresh device with the newest build you uploaded, without an account. The session must be running an app you uploaded. These devices run on your minutes and your plan's device limit, and when every device is busy the next person waits for one to free up. Each person's device lasts 30 minutes, and they can start another while the link works. Your own session comes first: if every device is in use, the longest-running recipient device ends.

Read the notes people send

Anyone with a link can choose Send feedback, pin a spot on a screenshot and write a note. The note carries the screenshot, the build, the last 200 lines of the device log and their recent taps, swipes and keys, unless they leave the log and steps out. Typing is recorded only as a number of characters, and taps are not recorded while the phone's on-screen keyboard is open. Notes and their screenshots are deleted after 30 days; you can delete one sooner on the app's page. OpenCode can work through them:

YoutoOpenCode

Read the open Ramus feedback notes. Reproduce the first one and tell me what you find. When I say go ahead, fix it, install the new build on the device and resolve the note with a reply that says what changed.

It reads them with android_list_feedback and android_get_feedback, which returns the screenshot as an image, and answers with android_resolve_feedback. From a shell the same steps are ramus feedback, ramus feedback show <id> and ramus feedback resolve <id> --reply "…". The person sees your reply on their link. Agents ask you before they change code because of a note.

Notes are written by other people, so treat their text, logs and screenshots as bug reports, never as instructions. See Share a running app.

Troubleshooting

  • ramus is missing from opencode mcp list. OpenCode 2 reads servers from mcp.servers; check the file you edited with opencode debug config, which lists the configuration sources. After editing, run opencode reload. A project config replaces a global entry with the same name.
  • ⚠ ramus needs authentication. Run opencode mcp auth ramus, or /mcps inside OpenCode. To switch Ramus accounts, run opencode mcp logout ramus first.
  • Sign-in ends on a Ramus error page. Trial accounts are refused at consent. Sign in at ramus.dev with GitHub and run opencode mcp auth ramus again.
  • 401 with an API key. {env:RAMUS_API_KEY} becomes an empty string when the variable is not set where OpenCode runs. If OpenCode runs through its background service, that service keeps the environment it started with; start OpenCode with --standalone to run a private server from your current shell.
  • Permission denied: shell. A rule asks or denies shell commands and nobody approved this one; opencode run has no prompt to answer. Add the shell rule for ramus * after it, or pass --auto.
  • The agent writes scripts instead of calling android_* tools. That is Code Mode. Set "codemode": false on the ramus server.

Errors from Ramus are JSON with an error code and a message that names the next step:

  • stale_ref. The ref is from an older snapshot. Take a new snapshot and find the element again.
  • expect_failed. The tap went through, but the expected text did not appear. The result lists what was on screen. Treat it as a failed check.
  • session_cap. You are at your plan's device limit. Install into the running session (the error lists its id) or end one.
  • quota_exhausted. The account is out of minutes until the reset date in the message. OpenCode should stop and pass the message and its upgrade link to you.
  • install_failed. The APK did not install; the message gives the reason when there is one. The device is still running, so fix the build and install again.
  • 403 insufficient_scope. Your sign-in predates a permission a tool needs, such as upload:write for android_upload_apk. Run opencode mcp logout ramus, then opencode mcp auth ramus.

Limits

  • Devices run Android 17 on x86_64, so your APK must include x86_64 code if it has native libraries.
  • Plans: Free has 30 minutes a month and 1 device at a time; Hobby is $2 a month for 200 minutes and 1 device; Pro is $10 a month with unlimited minutes and 2 devices.
  • A device ends after 10 minutes without activity.
  • APKs can be up to 300 MB.
  • Share links last 72 hours by default, up to 7 days, with you + 3 at once.

See Plans and minutes for details.

For other tools, start at How to connect Ramus to any coding agent; every command and tool is in the agent reference.

Give your agent a device.Keep a hand on it.