open-android-models speaks the same JSON-RPC 2.0 bridge protocol as open-apple-models, version 1.0. The
reference is open-apple-models' docs/PROTOCOL.md.
It covers framing, ordering guarantees, every method, notification and parameter, the error table, tool definitions,
JSON Schema support and scripted models. This page lists only what differs on Android.
A game engine implements the protocol once. On Apple platforms it loads the Apple backend (oam_bridge_* C ABI or
oam stdio). On Android it loads this one (OamJni, below). The method names, parameters, result fields, event types,
tool/call round trips, error codes (data.code strings) and ordering guarantees are the same on both.
The implementation is BridgeEngine in the oam-bridge module. Its game methods come from GameExtension, and it is
wired into OamJni in oam-mlkit.
-
Unit tests.
./gradlew :oam-bridge:testports open-apple-models' bridge test suites, using scripted models. -
Conformance.
AppleConformanceTestruns the same scripted JSON-RPC scenarios through Apple's ownoam stdioand throughBridgeEngine, then compares the exchanges: responses, event sequences,tool/callandtool/cancelparams, andworld/changed. The differences described on this page are normalized before comparing. The last run matched 90 of 90 exchanges.OAM_APPLE_BRIDGE=/path/to/oam ./gradlew :oam-bridge:test --tests '*AppleConformanceTest*' -
Live model.
ProxyBridgeEvalTestdrives sessions, NPCs, decisions and content against a real small model throughoam serve(OAM_PROXY_EVAL=1). -
Real JNI.
JniProbeTestinoam-mlkitdrives the bridge through a native host library (OAM_JNI_PROBE_LIB).
| Transport | How |
|---|---|
| JNI (native hosts such as Rust, C or C++ engines) | OamJni.create(context, nativeHandle) returns a bridge id. OamJni.send(id, line) takes one message per call and never blocks. Every outgoing message arrives through the host's nativeDeliver(nativeHandle, line). OamJni.destroy(id) ends the bridge. |
| Kotlin, in process | BridgeEngine(configuration) { line -> … } with receive(line), or call(method, params, id). This mirrors the Swift BridgeEngine. |
There is no stdio server and no C ABI on Android. The JNI transport plays the role of the C ABI:
- Framing. Each
sendand eachnativeDelivercarries exactly one JSON message, with no trailing newline. - Delivery.
nativeDeliverruns on a JVM background thread that is already attached. Deliveries are in order and never concurrent, so the callback is never re-entered. - Destroy. Once
destroyreturns,nativeDeliveris never called again for that handle, so the handle can be freed. Destroying cancels the bridge's work the wayoam_bridge_destroydoes: turns in flight get no response. - Registering
nativeDeliver. The host either exportsJava_com_spacecorps_oam_jni_OamJni_nativeDeliver(JNIEnv*, jclass, jlong, jstring)or registers it withRegisterNatives. Libraries loaded byNativeActivityorGameActivityare not searched for JNI symbols, so those hosts must useRegisterNatives. - Finding the class. Look up
com.spacecorps.oam.jni.OamJnithrough the activity's class loader.FindClasson a native thread only sees system classes. - Building the app. The app must be built with Gradle, so that the ML Kit AAR and its manifest entries are merged,
with the host's
.sounderjniLibs. - Text encoding. JNI passes strings as modified UTF-8. In that encoding, characters outside the Basic
Multilingual Plane, such as emoji, become two 3-byte surrogates that strict UTF-8 decoders reject.
- Outgoing messages: the bridge writes such characters as JSON
\uXXXX\uXXXXescapes. Every delivered line is therefore valid UTF-8, and the JSON value is unchanged. - Incoming messages: build the
jstringwithNewString(UTF-16) or thejnicrate'snew_string, or escape non-ASCII characters as\usequences. Do not pass raw UTF-8 containing emoji toNewStringUTF.
- Outgoing messages: the bridge writes such characters as JSON
- Blocking calls. There is no blocking call such as
oam_call_blocking, so error-32024 timeoutis never produced. Kotlin hosts can useBridgeEngine.callfrom a coroutine instead. - Choosing the engine.
OamJni.engineFactorydefaults toBridgeEngineFactory. All bridges share one lazily createdGeminiNanoModel. If the ML Kit client cannot be created,"system"requests fail withmodel_unavailableand scripted models keep working. Replace the factory inApplication.onCreateto change the model options, limits or extensions. - Foreground only. Gemini Nano runs only while the app is the top foreground app. In the background, turns fail
with
-32005 rate_limited.
-
server.nameis"open-android-models". -
The
modelobject (also returned bymodel/availability) keeps Apple's five fields and adds a few:Field Android available,variantas on Apple. variantis the Gemini Nano model name when knownreasondevice_not_eligible,model_not_ready(downloadable or downloading),aicore_unavailable,needs_system_update,not_enough_disk_spaceorunknown. Apple'sapple_intelligence_not_enableddoes not occurcontextSizethe input token budget per request, about 4000. Output has its own cap. On Apple, input and output share one 8192-token window supportedLanguagesalways [], because ML Kit cannot be askedstatus(new)available,downloadable,downloadingorunavailable, so a host can offer the downloaddetail,maxOutputTokens,bytesDownloaded,totalBytes(new)a readable reason, the output cap (up to 4096), and download progress -
capabilitiesis the same as on Apple, including every method, the notifications,clientRequests: ["tool/call"],models: ["system", "scripted"],maxSessionsandbatch: false.
Gemini Nano has no native tool calling. Each tool decision is a model step of its own: the model answers with a JSON
step envelope, {"action": "<tool>" | "respond", "arguments": {…}}. See DESIGN.md. What changes on the
wire:
-
More steps per turn. Where Apple takes one inference to call a tool and one to answer, Android takes a decide step, then the tool round, then another decide step (or none once the budget is spent), then the reply step. Each step sends a
modelStepevent. Don't infer tool rounds by counting steps; usetoolCalls. -
Two extra fields on each step.
result.steps[]andmodelStepevents carrykind(decide,toolArguments,respondorstructured) andisRepair(true when the step re-asks after invalid output). Everything else matches Apple.toolCallingModeis:allowedfor a decide step, which offers the tools andrespondrequiredfor a step that must call a tooldisallowedfor the reply step
-
"auto"behaves like"explicit". Every decision is explicit on Android, so either way the model chooses between the tools andrespond."required"and{"tool": name}force a call on the first step only, as on Apple. With{"tool": name}the decision is skipped entirely: the model writes only the arguments, or does nothing at all if the tool takes none. -
One tool call per step. Apple can run several calls of one step in parallel. Android makes at most one call per decide step, so the
tool/callrequests of a multi-call turn arrive one at a time, each after the previous answer. -
A repeated call ends the round. An identical successful call repeated within a turn is treated as the model choosing to reply. Small models otherwise repeat lookups.
-
Timing. These numbers come from
ProxyBridgeEvalTest, with Apple's ~3B model standing in for Gemini Nano; nothing has been measured on a Nano device yet. A decide step takes about 0.75 s.Request Time plain session turn 1.2–2.3 s session turn with one client tool 1.8–3.5 s, or up to 5.6 s when the decision needs a repair NPC turn with a grounding client tool 2.0–4.4 s npc/bark0.6–1.4 s decision/decide1.3–2.1 s content/generate0.7–1.5 s
Apple constrains decoding to the generation schema. Gemini Nano has no constrained decoding for runtime schemas, so the bridge describes the schema in the prompt, then coerces, validates and repairs once. What this means for hosts:
- Undeclared keys are dropped. Object schemas that declare
propertiesbut notadditionalPropertiesare treated as"additionalProperties": false, so arguments and structured output never contain undeclared keys, as on Apple. An explicitadditionalPropertiesis kept. Members ofallOfstay open. tool/callarguments always match the schema. Arguments that are still invalid after the repair are recorded intoolCallsas an error output ("The call was not made: …"), and notool/callis sent.- An unusable structured answer fails the turn with
-32012 generation_failed. This can't happen on Apple.- NPC fallback lines (
fallbackOnGuardrail) and a decision'sfallbackOptionIDcover it, as well as guardrail violations and refusals. - In NPC
automaticmode, it triggers the plain-text retry.
- NPC fallback lines (
- Enforced constraints.
pattern,minLength,maxLength,exclusiveMinimumandexclusiveMaximumare validated, with a repair, instead of being described and warned about. Warnings name the keywords that are ignored, such asformat,multipleOfanduniqueItems. schema/validateandtools/validate.generationSchemais the JSON Schema as it is enforced (closed as described above). There is also an extrarenderedfield: the text the model is shown.data.schemaPathis more precise. It points at the offending keyword (#/type) rather than the schema node (#).
-
Transcripts are platform-specific.
session/transcriptreturns{"type": "open-android-models.Transcript", "version": "1.0", "transcript": {…}}. Pass it back ashistory, as on Apple. Transcripts, and thetranscriptinsidenpc/state, cannot be moved between platforms. Instructions and tool definitions are restored from them as on Apple. -
entriesinsession/listcounts prompts, responses, tool calls and tool outputs, as on Apple. -
Options:
Option Android temperatureML Kit accepts 0–1; higher values are clamped sampling"greedy"means top-K 1.{"topK": n, "seed"?}works as on Apple.{"topP": p}is accepted with a warning and ignored, apart from itsseed, because ML Kit has no nucleus samplingmaxResponseTokenscapped at 4096 trimHistory,reservedResponseTokensTrimming fits the ~4000-token input budget. reservedResponseTokensis the safety margin below it (default 256; Apple's default is 1024)maxAttemptsRetries a single model step, never a whole turn. Default 3 (Apple: 2) userLabel,assistantLabel(Android only)How the two sides are labelled in the conversation the model reads, default User/Assistant. For a character, usePlayerand its name. Apple warns about these as unknown keys -
session/compactwrites its summary with the session's own model.
- NPC
toolChoicedefaults to"explicit". That is also what open-apple-models' code does, although its PROTOCOL.md says"auto". On Android the two behave the same. - Structured NPC replies use the keys
emotion,line,player_optionsandends_conversation, as in open-apple-models' scripts. Close variants such asplayerOptionsare matched too. Keep personas short: the model has about 4000 input tokens for everything. decision/decideManystarts its decisions in request order, each after taking one ofmaxConcurrencyslots.- Limits are the same as on Apple: 64 sessions, 128 NPCs, 64 worlds and 256 subscriptions.
The error table is Apple's. ML Kit failures map onto it like this:
| ML Kit | Code |
|---|---|
BUSY, per-app or per-device battery quota (27, 28), BACKGROUND_USE_BLOCKED (30) |
-32005 rate_limited, with data.retryAfter and data.retryAfterSeconds when ML Kit gives a delay |
| safety filters (4, 11, 15) | -32002 guardrail_violation |
REQUEST_TOO_LARGE |
-32004 context_size_exceeded |
| not available or supported, no disk space, needs a system update, AICore incompatible | -32001 model_unavailable |
| output still invalid after a repair; anything else | -32012 generation_failed |
-32024 timeout is never produced (see section 1). Error messages may be worded differently from Apple's. Branch on
code and data.code.
Scripts are written exactly as for open-apple-models, and the same script plays the same way on both platforms.
- Decide steps. The scripted model answers the agent's decide steps from the script. A
toolCallsstep becomes one step envelope per call. An answer step (text,jsonortemplate) makes the decisionrespondand is then played as the reply. - Calls run one after another. The calls of one
toolCallsstep run in sequence, not in parallel. - Call ids are generated. A scripted
"id"is ignored; call ids are alwayscall_….
The extension API mirrors the Swift one:
BridgeExtension:register(registry, engine),notificationMethodsandshutdown()BridgeMethodRegistry.register(method) { request -> … }BridgeReply.ResultorBridgeReply.Deferred { … }BridgeRequest.drive(run, stream, context, eventMethod)runs an agent turn with streaming and client toolsBridgeSession.schedule { … }andBridgeSession.commit()give per-object orderingBridgeCodingandGameCodinghold the shared JSON shapes
Put the extension in BridgeConfiguration(extensions = listOf(GameExtension(), QuestMethods())). For the JNI
transport, set it through BridgeEngineFactory(configure = { model -> BridgeConfiguration(systemModel = model, extensions = …) }).