Construir sobre WireCat

Importar datos de negocio

Un solo formato JSON lleva al almacén clientes, negociaciones, contratos, pedidos, facturas, pagos, datos bancarios, datos del teléfono y los objetos propios de una fuente, con las reglas que deciden qué espera al propietario.

Tienes datos de negocio en otro sistema: un ERP, una exportación de un CRM, un extracto bancario, una hoja de cálculo o una copia del teléfono. Quieres llevarlos al almacén local del propietario, para que su agente de IA responda «cuánto debe este cliente, bajo qué contrato, y si está pagado» con referencias y no con suposiciones. Esta página te da el único formato que usa cualquier importación, sus reglas y un ejemplo completo, del archivo a las respuestas del agente.

Primero unas palabras. El sobre de registros es ese formato: un documento JSON, o JSONL con un registro por línea. Cada elemento es un registro de origen, lo que tu fuente dice de una cosa. cli-core convierte un registro de origen en una entidad (una persona, una negociación, un contrato) o en una propuesta, dinero que solo cuenta cuando el propietario lo confirma. La página de conceptos explica las entidades y los registros de origen con más detalle.

Para quién es esta página

  • Construyes una herramienta de línea de comandos sobre WireCat y tu servicio tiene registros de negocio.
  • Escribes un script puntual que lleva una exportación al almacén.
  • Eres un agente de IA y te piden importar un archivo, o encontrar lo que trajo una importación.

Un formato, tres entradas

Toda importación lee el mismo sobre, schema_version: 1, descrito por el JSON Schema. Llega al almacén de una de tres maneras:

  • Un archivo JSON o JSONL, que lee records import de la herramienta memo.
  • La entrada estándar, el mismo comando con - en lugar del nombre del archivo, así otro programa puede enviarle datos por una tubería.
  • Desde tu propio código, con ingest o ingestLines de @wirecat/cli-core/services.

Un sobre JSON tiene schema_version, source y records. En JSONL, la primera línea no vacía lleva schema_version, source y el options opcional, y cada línea siguiente es un registro. JSONL se lee línea a línea y se escribe cada 1000 registros, así que un archivo de cualquier tamaño nunca está entero en memoria.

{
  "schema_version": 1,
  "source": { "provider": "example-erp", "account": "demo", "batch_id": "2026-11-07" },
  "options": { "lines": "products" },
  "records": [
    { "key": "org", "type": "organization", "external_id": "C-1", "operation": "upsert", "revision": 1,
      "fields": { "name": "ООО Пример" }, "relations": [] }
  ]
}
  • source.provider nombra tu sistema y source.account la cuenta en él. Juntos son la cuenta a la que pertenecen los registros. Los registros nunca coinciden entre cuentas.
  • Un registro tiene una key (única en el archivo, el recibo la cita), un type, el id propio de la fuente en external_id, una operation (upsert o delete), sus fields (como mucho 64 KiB), una revision opcional y como mucho 100 relations.
  • options.lines con el valor products lee el product de una línea como el id externo de un registro de producto. Con el valor por defecto, text, en una línea de factura es un dato más. Las líneas de pedido siempre lo leen como producto.
  • options.phone_region, un país de dos letras como GB, es el país en que se lee el teléfono de una persona sin +.

Elige un tipo

Elige el tipo en este orden:

  1. Un tipo que WireCat ya tiene. Si tu objeto es un cliente, una negociación, un contrato o un pago, envíalo con ese tipo, aunque tu sistema lo llame de otra forma. Así cada herramienta y cada agente lo leen igual.
  2. custom:<name> para un objeto que WireCat no modela, como un presupuesto, un ticket o una franja de entrega. Se guarda tal como lo enviaste, con su JSON anidado y sus vínculos. Nunca se convierte en entidad, y nadie lee dinero de él.
  3. Una nueva entidad compartida solo cuando una pregunta del propietario necesita reglas propias, como un total, un estado o una regla de coincidencia. Es un cambio en cli-core, no una tabla en tu herramienta. Abre primero un issue en cli-core.

Un tipo desconocido se rechaza con record.type. Nunca se convierte por sí solo en un registro propio, así que una errata en el nombre del tipo aparece en el recibo en lugar de esconderse en el almacén. task también se rechaza, las tareas tienen sus propias reglas de origen.

Los tipos

Una importación nunca escribe dinero confirmado. Todo lo que entra en un total de dinero llega como propuesta, y solo el propietario la confirma o la rechaza en la lista de revisión de memo. El resto se escribe directamente.

TipoEn qué se conviertePropuesta
personuna persona, encontrada o creada (ver coincidencias)no
organizationuna organización, encontrada o creadano
projectun proyecto, encontrado por su clave o creadono
productun producto o servicio con su precio, en el catálogono
pipelineun pipeline de ventas con sus stagesno
deal, leaduna negociación o un lead, la misma fila que escribe la sincronización del CRMno
contractun contrato, con fechas y condiciones de renovación y avisosí, cuando tiene un valor. Sin valor, se escribe
orderun pedido con sus linessí
invoiceuna factura con sus linessí
paymentun pago, su parte en cada factura que settles, o un reembolsosí
subscriptionuna suscripción con sus prices, que nunca se sobrescribensí
walletuna cuenta bancaria, una tarjeta o efectivono. Su saldo inicial queda en el registro hasta que el propietario lo fija
transactionuna línea de un extracto bancario, con sus splitssí
message con kind: "sms"un SMS en un chat (ver datos del teléfono)no
event con kind: "call"una llamada, como eventono
custom:<name>solo un registro, guardado tal como llegóno

Los fields conocidos de cada tipo tienen su tipo en el esquema. Cualquier otra clave se guarda tal como llega. El dinero es una cadena decimal, como "10000.00", con su currency al lado.

Vínculos

Un registro apunta a otros registros con relations. Cada vínculo tiene un role y un destino:

  • Otro registro de la misma cuenta: { "role": "counterparty", "type": "organization", "external_id": "C-1" }.
  • Una entidad que ya está en el almacén, por su referencia con tipo: { "role": "counterparty", "ref": "organization:12" }.

El tipo decide qué roles conoce. Una factura tiene issuer, counterparty, project, contract y subscription. Un pago tiene payer, payee, settles (con un amount, su parte de esa factura) y refund_of. Una negociación tiene organization, person y project. Un rol que se toma una vez, como el counterparty de una factura, se rechaza si apunta a dos destinos distintos (record.relation-ambiguous).

  • as dice qué papel tiene una persona u organización, como "as": "decision-maker" en la person de una negociación o "as": "customer" en la party de un contrato. Se convierte en una relación de negocio que informa tu cuenta. La relación propia del propietario para el mismo par gana, y borrar el registro la retira.
  • Un rol que el tipo no tiene es una palabra propia de tu fuente, como quoted-for o parent-ticket. Se guarda tal como lo enviaste, se resuelve de registro a registro y nunca se convierte en otra cosa.
  • Un destino que todavía no está deja el registro en espera. Un pago de una factura que envías mañana espera, y se escribe cuando llega la factura, en esta ejecución o en una posterior. El recibo nombra el rol que falta.

Cómo se encuentran personas, organizaciones y proyectos

Un registro de persona, organización o proyecto se vincula a una entidad que el almacén ya tiene solo por un identificador fuerte. Nunca por el nombre.

TipoIdentificadores fuertes
personfields.email y fields.phone (uno o una lista), y el id de contacto propio de tu fuente
organizationfields.identifiers: { "scheme": "tax-id:ru", "value": "7700000000" }, con esquema email, domain, tax-id:<país> o registration:<país>
projectfields.key
  • Coincide exactamente una entidad: el registro se vincula a ella, y la importación no le añade nada.
  • Coinciden dos o más: no se crea nada y no se vincula nada. El registro espera al propietario, aparece en el recibo bajo unmatched con sus candidates. Solo el propietario dice cuál es. Un agente solo puede informar de ello.
  • No coincide ninguna: la importación crea una entidad nueva, la marca como creada por la importación y le da los identificadores del registro.

Un e-mail se compara sin espacios en los extremos y en minúsculas. Un número de teléfono se compara en su forma internacional, como +447700900123. Un número escrito sin + ni 00 solo se lee con una región: el options.phone_region del sobre, o si no el ajuste phone_countries del almacén cuando exactamente uno de sus países lo lee como válido. Sin región no es un identificador. El recibo lo muestra en ignored (record.phone-region), y el registro crea una persona nueva en lugar de encontrar la que existe. Envía los números con + cuando los tengas.

Un dominio de correo gratuito como gmail.com se rechaza como domain de una organización (record.identifier).

Revisiones, repeticiones y borrados

  • Repetir no cambia nada. El mismo archivo dos veces da unchanged en cada registro. Los mismos campos y vínculos en otro orden cuentan como el mismo registro.
  • Las revisiones solo suben. Cuando un registro tiene revision, cada versión posterior la envía. Una revisión menor, o la misma revisión con otro contenido, se rechaza con record.stale-revision.
  • Un registro cambiado actualiza lo que se escribió directamente. Una propuesta que hizo la importación se actualiza mientras espera al propietario. Cuando el propietario ya decidió, el cambio solo se marca en el recibo, y su decisión se mantiene. Las facturas siempre se marcan, como en la primera versión del formato.
  • Los borrados son suaves. "operation": "delete" marca el registro como borrado. Su entidad se queda y aparece en flagged, para que el propietario decida qué hacer con ella. Un registro propio no tiene entidad, así que no se marca nada.

El recibo y los errores

ingest e ingestLines devuelven este recibo. Con --json, records import y records check lo imprimen dentro de item, con los nombres en snake case: dry_run y batch_id.

MiembroQué contiene
dryRuntrue cuando no se escribió nada
sourceel proveedor, la cuenta y el batchId
created, updated, unchanged, deleted, refusedcuentas exactas
proposedlas propuestas creadas o cambiadas, como invoice:1
waitingregistros con un destino que falta: key, pointer, los roles en missing
unmatchedregistros con dos o más candidatos: key, pointer, reason, candidates
ignoredvalores que parecen un identificador y se guardaron solo como datos: key, pointer, reason
relationshipslas relaciones de negocio que informa la cuenta: added, kept, restored, withdrawn, proposed y las omitidas porque el propietario las rechazó
noticesregistros aceptados, pero no como llegaron, como una palabra de relación fuera de su lista de roles (relationship.role-unlisted)
flaggedregistros cambiados o borrados cuya entidad no se tocó
errorsrechazos: pointer, code, message, key
truncatedtrue cuando una lista se detuvo en 1000 elementos. Las cuentas siguen siendo exactas

El pointer es un JSON Pointer dentro del sobre, como /records/0/type, así tu herramienta puede mostrar el lugar exacto. Un registro malo se rechaza y el resto se escribe. Un error en el propio sobre (envelope.invalid, envelope.schema-version, envelope.account-mismatch) no deja pasar ningún registro.

Este es el recibo de un archivo con un tipo desconocido y un pago cuya factura todavía no ha llegado:

{
  "dryRun": false,
  "source": { "provider": "example-erp", "account": "demo2", "batchId": "b2" },
  "created": 1,
  "updated": 0,
  "unchanged": 0,
  "deleted": 0,
  "refused": 1,
  "proposed": [],
  "waiting": [{ "key": "p", "pointer": "/records/1/relations", "missing": ["settles"] }],
  "flagged": [],
  "unmatched": [],
  "ignored": [],
  "relationships": { "added": 0, "kept": 0, "restored": 0, "withdrawn": 0, "proposed": 0, "skippedOwnerRejected": 0 },
  "notices": [],
  "errors": [
    {
      "pointer": "/records/0/type",
      "code": "record.type",
      "message": "a type is one of invoice, payment, person, organization, project, order, product, contract, subscription, wallet, transaction, deal, lead, message, event, pipeline or custom:<name>",
      "key": "t"
    }
  ],
  "truncated": false
}

Una ejecución de prueba revisa todo el archivo y no escribe nada. Su recibo es el real, con las referencias entre registros. Se ejecuta como una sola transacción que se deshace al final, así que tg, max y memo esperan al almacén mientras dura. Una ejecución real confirma cada 1000 registros. Si se detiene a mitad, los registros anteriores quedan escritos, y volver a ejecutarla es seguro.

Datos del teléfono: SMS, llamadas y contactos

Una exportación del teléfono es un sobre de un teléfono, JSON o JSONL, que se importa en una sola cuenta, como phone:pixel. Los registros, los SMS y las llamadas quedan en esa cuenta, así que un grupo de cuentas muestra el teléfono una vez.

  • Un SMS es un message con kind: "sms". Se escribe en un chat por conversación, y la búsqueda lo lee como los mensajes de un servicio de mensajería.
  • Una llamada es un event con kind: "call", guardado en el calendario Calls propio de la cuenta del teléfono. Nunca cuenta como ocupado. memo events list la muestra junto a las reuniones de todas las cuentas de calendario, por defecto de los últimos 30 días y los 7 siguientes. memo events list event_type:call deja solo las llamadas. Para encontrar una llamada por su número, usa memo search all "+447700900123" --only events.
  • La persona del otro lado es un registro person que envía la fuente, uno por número. El vínculo counterpart la nombra.
{ "key": "sms-1", "type": "message", "external_id": "sms-1", "operation": "upsert",
  "fields": { "kind": "sms", "direction": "in", "phone": "+447700900123", "name": "Иван Петров",
              "body": "Буду в 10", "sent_at": "2026-11-01T09:12:00+01:00", "read": true },
  "relations": [{ "role": "counterpart", "type": "person", "external_id": "+447700900123" }] }
{ "key": "call-1", "type": "event", "external_id": "call-1", "operation": "upsert",
  "fields": { "kind": "call", "direction": "missed", "phone": "+447700900123", "started_at": "2026-11-01T10:00:00Z",
              "duration": 0 },
  "relations": [{ "role": "counterpart", "type": "person", "external_id": "+447700900123" }] }

Un SMS o una llamada se vincula a una persona solo a través de ese registro de persona. Ese registro se busca como cualquier persona: un número exacto vincula, ninguno crea una persona, dos o más dejan el SMS o la llamada esperando al propietario. Un registro sin counterpart guarda solo el número.

Un archivo de contactos en formato vCard (.vcf) se convierte en el mismo sobre. memo lo importa en la cuenta del teléfono:

memo records import contacts.vcf --account phone:pixel --json

Desde tu propio código, ingestVcard de @wirecat/cli-core/services lo lee y lo importa en una sola llamada, con una region en minúsculas, como gb, para los números nacionales. Sin región, un número nacional se guarda pero no se busca, y la lista vcard del recibo lo dice.

Un ejemplo completo

Un cliente inventado, de principio a fin. Un ERP llamado example-erp, cuenta demo, envía la organización del cliente con su número fiscal, una persona con su e-mail que trabaja allí, un pipeline de ventas, una negociación con esa persona como quien decide, un servicio, un contrato con valor, un pedido de venta con una línea de ese servicio, una factura bajo el contrato, el pago que la salda y un presupuesto, un objeto que WireCat no modela, vinculado a la negociación.

El archivo

erp.jsonl, un registro por línea:

{"schema_version": 1, "options": {"lines": "products"}, "source": {"provider": "example-erp", "account": "demo", "batch_id": "2026-11-07"}}
{"key": "org", "type": "organization", "external_id": "C-1", "operation": "upsert", "revision": 1, "fields": {"name": "ООО Пример", "identifiers": [{"scheme": "tax-id:ru", "value": "7700000000"}]}}
{"key": "anna", "type": "person", "external_id": "P-1", "operation": "upsert", "fields": {"name": "Анна Образцова", "email": "[email protected]"}, "relations": [{"role": "member-of", "type": "organization", "external_id": "C-1"}]}
{"key": "pipe", "type": "pipeline", "external_id": "sales", "operation": "upsert", "fields": {"kind": "deal", "name": "Sales", "stages": [{"external_id": "new", "name": "New", "position": 1, "outcome": "open"}, {"external_id": "won", "name": "Won", "position": 2, "outcome": "won"}]}}
{"key": "deal", "type": "deal", "external_id": "D-7", "operation": "upsert", "fields": {"title": "Rental fleet 2027", "pipeline": "sales", "stage": "new", "amount": "120000.00", "currency": "RUB"}, "relations": [{"role": "organization", "type": "organization", "external_id": "C-1"}, {"role": "person", "type": "person", "external_id": "P-1", "as": "decision-maker"}]}
{"key": "svc", "type": "product", "external_id": "SKU-RENT", "operation": "upsert", "fields": {"kind": "service", "name": "Bike rental, month", "unit": "month", "unit_price": "10000.00", "currency": "RUB"}}
{"key": "contract", "type": "contract", "external_id": "K-2026-14", "operation": "upsert", "fields": {"name": "Rental agreement", "kind": "service", "status": "active", "effective_date": "2026-11-01", "expiration_date": "2027-10-31", "auto_renew": true, "notice_days": 30, "value": "120000.00", "currency": "RUB"}, "relations": [{"role": "party", "type": "organization", "external_id": "C-1", "as": "customer"}, {"role": "deal", "type": "deal", "external_id": "D-7"}]}
{"key": "order", "type": "order", "external_id": "O-55", "operation": "upsert", "fields": {"number": "O-55", "direction": "sale", "purpose": "business", "order_date": "2026-11-02", "currency": "RUB", "lines": [{"product": "SKU-RENT", "quantity": "12", "unit_price": "10000.00"}]}, "relations": [{"role": "customer", "type": "organization", "external_id": "C-1"}, {"role": "contract", "type": "contract", "external_id": "K-2026-14"}]}
{"key": "inv", "type": "invoice", "external_id": "INV-901", "operation": "upsert", "fields": {"direction": "receivable", "number": "901", "currency": "RUB", "issue_date": "2026-11-03", "due_date": "2026-11-17", "lines": [{"product": "SKU-RENT", "description": "Rental, November", "quantity": "1", "unit_price": "10000.00", "amount": "10000.00"}]}, "relations": [{"role": "counterparty", "type": "organization", "external_id": "C-1"}, {"role": "contract", "type": "contract", "external_id": "K-2026-14"}]}
{"key": "pay", "type": "payment", "external_id": "PAY-3", "operation": "upsert", "fields": {"direction": "in", "amount": "10000.00", "currency": "RUB", "date": "2026-11-05"}, "relations": [{"role": "payer", "type": "organization", "external_id": "C-1"}, {"role": "settles", "type": "invoice", "external_id": "INV-901", "amount": "10000.00"}]}
{"key": "quote", "type": "custom:quote", "external_id": "Q-7", "operation": "upsert", "fields": {"valid_until": "2026-11-30", "options": [{"bikes": 12, "insurance": {"kind": "full"}}]}, "relations": [{"role": "quoted-for", "type": "deal", "external_id": "D-7"}]}

El mismo archivo viene con cli-core como @wirecat/cli-core/schemas/examples/records-business.jsonl.

Importarlo como desarrollador

Revisa primero el archivo y luego impórtalo. --account nombra la fuente como <provider>:<account>:

memo records check erp.jsonl --account example-erp:demo --json
memo records import erp.jsonl --account example-erp:demo --json

El recibo de la primera importación:

{
  "item": {
    "dry_run": false,
    "source": { "provider": "example-erp", "account": "demo", "batch_id": "2026-11-07" },
    "created": 10,
    "updated": 0,
    "unchanged": 0,
    "deleted": 0,
    "refused": 0,
    "proposed": ["contract:1", "order:1", "invoice:1", "payment:1"],
    "waiting": [],
    "flagged": [],
    "unmatched": [],
    "ignored": [],
    "relationships": {
      "added": 3, "kept": 0, "restored": 0, "withdrawn": 0, "proposed": 0, "skipped_owner_rejected": 0
    },
    "notices": [],
    "errors": [],
    "truncated": false
  },
  "meta": { "run_id": "20261109T131039Z-process-5827f3", "duration_ms": 85, "complete": true }
}

Qué pasó:

  • Se crearon la organización y Anna, porque el almacén no tenía ese número fiscal ni ese e-mail.
  • Se escribieron el pipeline, la negociación y el servicio.
  • El contrato, el pedido, la factura y el pago son propuestas, y el pago está asignado a la factura.
  • El presupuesto se guarda como registro, y su vínculo quoted-for apunta al registro de la negociación.
  • El lugar de Anna en la organización, su papel en la negociación y el cliente del contrato son tres relaciones de negocio que informa example-erp:demo (relationships.added: 3).

Ejecuta otra vez la misma importación y cada registro queda unchanged: created: 0, unchanged: 10, proposed: [] y relationships.kept: 3.

Importar desde tu propia herramienta de línea de comandos

Tu herramienta puede enviar los mismos registros sin memo. Abre el almacén, pasa las líneas y muestra el recibo:

import { createReadStream } from "node:fs"
import { ingestLines, jsonlLines } from "@wirecat/cli-core/services"
import { openStore } from "@wirecat/cli-core/store"

const store = await openStore()
const input = process.argv[2] === "-" ? process.stdin : createReadStream(process.argv[2])
const receipt = await ingestLines(store, jsonlLines(input), {
  account: "demo",
  onIssue: (error) => console.error(error.pointer, error.code, error.message),
})
console.log(JSON.stringify(receipt))
  • account es el source.account del sobre. Otro valor se rechaza con envelope.account-mismatch.
  • onIssue recibe cada rechazo en el momento, también los que pasan de los primeros 1000 del recibo.
  • dryRun: true revisa y no escribe nada. chunk cambia los 1000 registros por transacción.
  • Para un sobre que ya tienes como un objeto JSON, llama a ingest(store, envelope, { account }).

Lo que ve un agente

Un agente no necesita conocer tu ERP. Le pregunta a memo. Primero qué espera al propietario, luego la negociación, el contrato, el registro propio y una búsqueda en los registros de negocio:

memo review list kind:invoice,payment,order,contract --json
memo organizations context organization:1
memo deals show deal:1 --json
memo contracts show contract:1 --json
memo relationships list 'about:"contract:1"'
memo orders show order:1 --json
memo invoices show invoice:1 --json
memo records list type:custom:quote --account example-erp:demo --json
memo records show custom:quote:Q-7 --account example-erp:demo --json
memo search all "Rental agreement" --only business --json
  • organizations context da la organización con Anna entre sus personas, member-of e informada por example-erp:demo, y su negociación abierta, factura, pago y pedido.
  • review list muestra las cuatro propuestas. El propietario las confirma, y solo entonces cuentan en los totales.
  • deals show da la negociación con su organización, Anna como quien decide, y sus registros de origen, entre ellos el presupuesto.
  • contracts show da las fechas, el valor, el plazo de aviso (1 de octubre de 2027, 30 días antes del 31 de octubre) y la negociación que cerró.
  • relationships list 'about:"contract:1"' da la parte del contrato: ООО Пример como customer, informada por example-erp:demo.
  • records show da los campos del presupuesto tal como llegaron, con el JSON anidado, y su vínculo con la negociación.
  • search all --only business busca en los títulos de facturas, pagos, pedidos, contratos, suscripciones, productos y cuentas. Cada resultado nombra la fuente de la que vino. Las transacciones bancarias y los registros propios no están, se encuentran con transactions list y records list.

Siguiente

Para traer un servicio entero de forma periódica, con inicio de sesión y sincronización, sigue Añadir una fuente.