The NFC Agent serves both roles from a single server on one port:
| Server | Port | Purpose |
|---|---|---|
| Agent Server | 9470 | Serves both NFC devices (hardware readers, smartphones, browsers) via /ws?mode=device and client applications via /ws |
| CA Bootstrap | 9472 | Serves TLS certificates for device setup |
The agent server port is configurable via -device-port (default 9470).
The device endpoint accepts connections from NFC devices that provide tag data.
Connect via WebSocket with device mode:
wss://[host]:9470/ws?mode=device
After connecting, register the device:
{
"type": "registerDevice",
"payload": {
"deviceName": "My Device",
"platform": "ios",
"appVersion": "1.0.0",
"capabilities": {
"canRead": true,
"canWrite": false,
"nfcType": "corenfc"
},
"metadata": {
"userAgent": "..."
}
}
}
Registration Response:
{
"type": "registerDeviceResponse",
"success": true,
"payload": {
"deviceID": "dev_abc123",
"serverInfo": {
"version": "1.0.0",
"supportedNFC": ["ndef", "mifare"]
}
}
}
Send when a tag is detected:
{
"type": "tagScanned",
"payload": {
"deviceID": "dev_abc123",
"uid": "04A1B2C3D4E5F6",
"technology": "ISO14443A",
"type": "MIFARE Classic 1K",
"scannedAt": "2024-10-06T12:34:56Z",
"ndefMessage": {
"records": [
{
"recordType": "text",
"content": "Hello, NFC!",
"language": "en"
}
]
}
}
}
Send when a tag leaves the reader:
{
"type": "tagRemoved",
"payload": {
"deviceID": "dev_abc123",
"uid": "04A1B2C3D4E5F6",
"removedAt": "2024-10-06T12:35:00Z"
}
}
Keep connection alive:
{
"type": "deviceHeartbeat",
"payload": {
"deviceID": "dev_abc123",
"timestamp": "2024-10-06T12:35:30Z"
}
}
Respond to a write request from the server:
{
"type": "deviceWriteResponse",
"payload": {
"requestID": "req_xyz789",
"success": true,
"error": ""
}
}
Server requests the device to write data to a tag:
{
"type": "deviceWriteRequest",
"payload": {
"requestID": "req_xyz789",
"deviceID": "dev_abc123",
"ndefMessage": {
"records": [
{
"type": "text",
"content": "Hello!",
"language": "en"
}
]
}
}
}
The agent advertises via mDNS/Bonjour:
_nfc-device._tcplocal.Devices can discover the agent on the local network without knowing the IP address.
The agent provides NFC data to client applications on the same port as devices
(plain /ws, without the ?mode=device query). This is the agent server port
(default 9470, configurable via -device-port).
Connect via WebSocket:
const ws = new WebSocket('ws://localhost:9470/ws');
With API secret:
const ws = new WebSocket('ws://localhost:9470/ws?secret=your-secret');
409 Conflict until first disconnects{
"type": "deviceStatus",
"payload": {
"connected": true,
"message": "Device connected",
"cardPresent": false
}
}
When a card is detected and read:
{
"type": "tagData",
"payload": {
"uid": "04A1B2C3D4E5F6",
"type": "MIFARE Classic 1K",
"technology": "ISO14443A",
"scannedAt": "2024-10-06T12:34:56Z",
"capabilities": {
"canRead": true,
"canWrite": true,
"canLock": true,
"maxNdefSize": 716,
"tagFamily": "MIFARE Classic",
"supportsNdef": true
},
"message": {
"type": "ndef",
"records": [
{
"tnf": 1,
"type": "T",
"text": "Hello, NFC!",
"payload": [72, 101, 108, 108, 111]
}
]
},
"text": "Hello, NFC!",
"err": null
}
}
Payload Fields:
| Field | Description |
|---|---|
uid |
Card unique identifier (hex string) |
type |
Card type: MIFARE Classic 1K, MIFARE Classic 4K, MIFARE DESFire, MIFARE Ultralight, ISO14443-4 Type 4A (experimental) |
technology |
NFC technology standard (ISO14443A, ISO14443B, etc.) |
scannedAt |
ISO 8601 timestamp |
capabilities |
What the tag supports — see Tag Capabilities |
message |
Structured NDEF message data |
text |
Quick access to first text record |
err |
Error message or null on success |
NDEF Message Structure:
{
"type": "ndef",
"records": [
{
"tnf": 1,
"type": "T",
"text": "Decoded text",
"language": "en",
"payload": [...]
}
]
}
tnf: Type Name Format (0x01 = Well Known)type: Record type (T = Text, U = URI)text: Decoded text (for Text records)uri: Decoded URI (for URI records)All client messages support an optional id field for request/response correlation.
Write NDEF data to a card (complete overwrite):
{
"id": "req_1",
"type": "writeRequest",
"payload": {
"records": [
{
"type": "text",
"content": "Hello, NFC!",
"language": "en"
}
]
}
}
Multiple records:
{
"id": "req_2",
"type": "writeRequest",
"payload": {
"records": [
{
"type": "text",
"content": "Hello, NFC!",
"language": "en"
},
{
"type": "uri",
"content": "https://example.com"
}
]
}
}
Record Fields:
| Field | Type | Required | Description |
|---|---|---|---|
type |
string | No | Record type (see below). Defaults to text. |
content |
string | Varies | Primary value: text, URI, domain, package name, etc. |
language |
string | No | ISO language code for text/smartposter (default: en) |
mimeType |
string | No | Media type for mime records |
title |
string | No | Display title for smartposter records |
payload |
bytes (base64) | No | Raw bytes for mime, vcard, external, raw |
tnf |
number | No | Type Name Format (0–7) for raw records |
typeBytes |
bytes (base64) | No | NDEF type bytes for raw records |
id |
bytes (base64) | No | Optional record ID for raw records |
Supported type values:
type |
Fields used | Notes |
|---|---|---|
text |
content, language |
Default when type omitted |
uri / url |
content |
Prefix is auto-abbreviated to save tag space |
mailto / email, tel, sms, geo |
content |
URI shortcut; scheme prepended if absent |
smartposter |
content (URI), title, language |
“Tap to open title” — URI + label |
mime |
mimeType, payload (or content) |
Arbitrary MIME media record |
vcard |
content or payload |
Contact card (text/vcard MIME) |
external |
content (domain:type), payload |
NFC Forum external type |
aar |
content (package name) |
Android Application Record (app launch) |
empty / erase |
— | Empty record — blanks/formats the tag (reversible) |
raw |
tnf, typeBytes, id, payload |
Fully custom record |
WiFi credentials can be written as a mime record with mimeType set to
application/vnd.wfa.wsc and a WSC-formatted payload.
Success:
{
"id": "req_1",
"type": "writeResponse",
"success": true,
"payload": {
"message": "Write operation completed successfully",
"uid": "04A1B2C3D4E5F6",
"tagType": "MIFARE Ultralight",
"bytesWritten": 28,
"verified": true,
"attempts": 1
}
}
The agent confirms every write before reporting success: it checks the encoded message against the tag’s capacity, retries transient failures, and reads the data back to verify it landed.
Success Payload Fields:
| Field | Type | Description |
|---|---|---|
message |
string | Human-readable status |
uid |
string | UID of the tag that was written |
tagType |
string | Detected tag type |
bytesWritten |
number | Size of the encoded NDEF message written |
verified |
bool | true when the write was confirmed by reading it back |
attempts |
number | Number of write attempts before success |
locked |
bool | true when the tag was made read-only (see below) |
A write that cannot be confirmed (verification mismatch after retries) returns an
error response rather than a success — success: true means the data is on the
tag. A response with verified: false only occurs if verification was explicitly
disabled by the agent.
Every tagData broadcast includes a capabilities object describing what the
present tag supports, so a client can gate its UI (show “lock”/”password” only
when supported, render a capacity meter, etc.) without a round-trip.
{
"canRead": true,
"canWrite": true,
"canTransceive": false,
"canLock": true,
"isReadOnly": false,
"memorySize": 540,
"maxNdefSize": 504,
"technology": "ISO14443A",
"tagFamily": "NTAG",
"supportsNdef": true,
"supportsPassword": true
}
| Field | Description |
|---|---|
canRead / canWrite |
Whether read / write operations are supported |
canTransceive |
Raw APDU transceive supported |
canLock |
Tag can be made permanently read-only |
isReadOnly |
Tag is already locked (omitted when false) |
memorySize |
Total memory in bytes (omitted when unknown) |
maxNdefSize |
Maximum NDEF message size in bytes (omitted when unknown) |
tagFamily |
MIFARE Classic, DESFire, NTAG, MIFARE Ultralight, Type 4, … |
supportsNdef |
Tag supports NDEF |
supportsPassword |
Tag supports simple password protection (NTAG21x PWD/PACK) |
Query on demand — to fetch capabilities without waiting for the next scan,
send a capabilitiesRequest:
{
"id": "req_cap",
"type": "capabilitiesRequest"
}
Response (type: "capabilitiesResponse"):
{
"id": "req_cap",
"type": "capabilitiesResponse",
"success": true,
"payload": {
"capabilities": { "canWrite": true, "canLock": true, "supportsPassword": true, "maxNdefSize": 504 }
}
}
The query requires exactly one tag to be present; if none (or several) are
present, success is false with an error.
Locking is irreversible — once a tag is made read-only it can never be written again. Only tags that support locking (e.g. NTAG, MIFARE Ultralight) can be locked; others return an error.
Write and lock in one step — add "lock": true to a write request:
{
"id": "req_1",
"type": "writeRequest",
"payload": {
"lock": true,
"records": [{ "type": "uri", "content": "https://example.com" }]
}
}
The write response then includes "locked": true.
Lock an already-written tag — send a lockRequest:
{
"id": "req_9",
"type": "lockRequest"
}
Response (type: "lockResponse"):
{
"id": "req_9",
"type": "lockResponse",
"success": true,
"payload": {
"message": "Lock operation completed successfully",
"uid": "04A1B2C3D4E5F6",
"tagType": "MIFARE Ultralight",
"locked": true
}
}
If the present tag does not support locking, success is false with an error.
Password protection (NTAG PWD/PACK/AUTH0) is not yet available. The
per-tag capability is reported (supportsPassword, true for NTAG21x) and the
API contract below is fixed, but the destructive configuration writes are gated
off pending validation on real hardware — a wrong AUTH0/ACCESS value can
permanently lock a tag. Calls currently return a not-supported error.
Planned request shape (subject to change until enabled):
{
"id": "req_10",
"type": "passwordRequest",
"payload": {
"action": "set", // "set" or "remove"
"password": "01020304", // hex, 4 bytes
"protectRead": false, // false = write-protect only
"startPage": 4 // first protected page (AUTH0)
}
}
Error:
{
"id": "req_1",
"type": "error",
"success": false,
"error": "Write failed: card removed",
"payload": {
"code": "WRITE_FAILED"
}
}
To append records, use read-modify-write:
// 1. Read current tag data
const currentData = await client.getLastTag();
// 2. Extract existing records
const existingRecords = currentData.message.records.map(r => ({
type: r.type === 'T' ? 'text' : 'uri',
content: r.text || r.uri,
language: r.language || 'en'
}));
// 3. Write back with new record appended
socket.send(JSON.stringify({
type: 'writeRequest',
payload: {
records: [...existingRecords, { type: 'text', content: 'New record' }]
}
}));
Base URL: http://localhost:9470/api/v1
GET /api/v1/health
curl http://localhost:9470/api/v1/health
Response:
{
"status": "ok",
"type": "agent"
}
Both /health and /api/v1/health are served on the agent server port and
report "type": "agent".
The agent uses auto-generated TLS certificates for secure WebSocket connections.
A bootstrap server runs on port 9472 to help devices trust the agent’s certificate:
http://[agent-ip]:9472 in a browseriOS:
Android:
Browsers:
| Code | Description |
|---|---|
WRITE_FAILED |
Write operation failed |
NO_CARD |
No card present on reader |
READ_FAILED |
Failed to read card data |
SESSION_LOCKED |
Another client holds the session |
INVALID_REQUEST |
Malformed request |