Run a Local Convex Network
SkillDev toolsRun isolated local Convex networks and in-process lattice node networks for development. Use when testing against local peers, reproducing peer or P2P replication issues, exercising NodeServer or P2PNode sync, or when no remote network is configured.
Available today. Use it from your connected AI after setup.
No other account needed.
Connect ahel once, and every AI you use reads what you have installed.
Then ask your AI: use the Run a Local Convex Network skill
What this skill tells your AI
The instructions your AI receives, as published by convex-dev/convex in .agents/skills/local-network/SKILL.md and read by ahel’s review.
A local network is the default way to exercise changes in this repository. It needs no credentials, no remote host, and can be thrown away and recreated freely. Prefer it over a remote network for anything that is not specifically about remote behaviour.
Requires a built convex.jar — see the build-convex skill.
Start a Temporary Network
java -jar convex.jar local start
Starts a throwaway test network. State is not preserved between runs, which is what you want for testing.
Useful options:
| Option | Effect |
|---|---|
--count N | Number of peers to launch |
--ports ... | Specific peer ports (default: assigned automatically) |
--api-port N | Port for the REST API |
--norest | Do not start the REST server |
--no-tray | No system tray icon |
--protocol-version N | Pin the protocol version |
Start with the Peer Manager GUI
java -jar convex.jar local gui
Launches the same local network under the peer manager GUI — useful for watching consensus and inspecting peer state visually.
Talking to It
Once running, point the client commands at the local peer:
java -jar convex.jar client query --host localhost --port <PORT> '(balance #12)'
java -jar convex.jar client status --host localhost --port <PORT>
Notes for Tests
Do not start a network from a JUnit test by shelling out to the CLI. Tests construct peers or lattice nodes in-process.
For consensus/CVM peer tests, follow the fixtures in convex-peer. For lattice
replication, use NodeServer; for the bundled P2P and social regions, use
P2PNode. See convex-peer/src/test/java/convex/node/LatticeNetworkTest.java
and convex-p2p/src/test/java/convex/p2p/P2PSocialSyncTest.java.
NodeServer is the schema-independent authoritative lattice host. It owns
merge, node-root persistence, attached-propagator lifecycle and isolated update
notification, but no socket or transport. It does not publish or interpret
NodeInfo, inspect :p2p / :social paths, or implement discovery policy. The
calling application must construct and configure every LatticePropagator
before attaching it; there is no implicit/default group. A zero-propagator node
is a valid local persistent host. Each propagator owns its connection manager,
transport identity, trust, bounded protocol endpoint, filters and serving store.
The calling application separately owns any LatticeListener: register every
eligible propagator, install a one-time connection selector, launch the listener
after the node, and close it before the node. One listener may route connections
to several groups, and groups may instead use independent listeners or custom
transports. P2PNode performs this composition; its NodeDirectory owns
signed [:p2p :nodes] publication, validation, discovery translation and PoP
metadata. Keep tests for those behaviours in convex-p2p, not convex-peer.
P2PNode also configures its node key explicitly as both its transport challenge
identity and its signed NodeInfo owner. A generic NodeServer does not infer a
transport key or propagator merge context from LatticeContext; direct tests
must set these on the propagator itself before addPropagator. Social user DIDs
and their account keys remain a separate application concern.
For a small lattice network test:
CAD036 envelopes: use
[:LV path value]for an optimistic push with no acknowledgement, and[:LV id path value]when the sender needs a Result after merge.Message.withIDupgrades the optimistic form without replacing its path. Lattice gossip and root sync use the lean optimistic form. Every:LVand:LQpath is a vector, with[]selecting the root; do not use nil, omitted or scalar paths. A four-field:LVwith a nil ID remains an accepted fire-and-forget wire representation, but is not needed for new optimistic pushes.
Security model: P2P data is public, so
serveAllInbound()may assign an untrusted inbound connection to the public propagator view. Assignment is not authentication and does not make the connection an outbound gossip route. An unverified connection may submit only completeLATTICE_VALUEmessages; path-aware admission runs before persistence, unsolicitedDATAis rejected, and missing-cell acquisition is reserved for verified connections. P2PNode's default social policy retains only local users, operator pins and direct active follows. The separate inbound upgrade verifies both challenge signatures, a random nonce, responder and challenger audiences, the fixed lattice-peer context and an admitted signed NodeInfo. Shared-listener connections are bounded byNodeConfig; each propagation group's desired peers, protocol state and queues are bounded byLatticePropagatorConfig.
For a direct NodeServer network test, the minimum composition is explicit:
NodeConfig nodeConfig = NodeConfig.localNetwork();
LatticePropagatorConfig groupConfig = LatticePropagatorConfig.create();
NodeServer<V> node = new NodeServer<>(lattice, nodeStore, nodeConfig);
node.setMergeContext(nodeContext);
LatticePropagator group = new LatticePropagator(
servingStore, lattice, value -> value, groupConfig);
group.setMergeContext(groupContext);
group.setTransportKeyPair(nodeKey);
node.addPropagator(group);
LatticeListener transport = new LatticeListener(nodeConfig);
transport.registerPropagator(group);
transport.setSelector(connection -> group);
node.launch();
transport.launch();
Retain group and pass it to explicit pull/route APIs. Do not recover it from
the node merely to treat the first group as a privileged "primary" group.
- Give each node its own store and key pair.
- Use
NodeConfig.localNetwork(). The application-owned listener binds port0; after it reports the actual OS-assigned port,P2PNodepublishes that loopback endpoint in the node's signedNodeInfo. - Set an inbound propagator policy before launch.
P2PNode.serveAllInbound()is suitable for a deliberately public test node. - Tell one node about the other with
nodeA.connect(nodeBKey, nodeB.getHostAddress()). The future completes after B proves its node key, A's own signed[:p2p :nodes]record has been merged by B, and A has pulled and merged B's:p2p,:id, and currently desired complete social-owner paths. B discovers A from the path-scoped identity update, challenges A on the same full-duplex socket, then explicitly upgrades that inbound connection to an authenticated outbound propagation route. The bootstrap never pulls a peer's complete root and social admission remains follow-filtered. UsenodeB.whenInboundConnectionUpgraded(nodeAKey)when a test must wait for that distinct reverse-route capability before publishing in both directions. For three nodes, a useful discovery topology is to tell both leaves only about one rendezvous node. Wait until its signed registry has reached both leaves, then usewhenConnectedto prove the leaves discovered each other without another configured endpoint. To test late joining, converge the initial nodes before creating the newcomer, tell only the newcomer about the rendezvous node, and requireconnect()itself to deliver the existing state without a manual rendezvous-nodesync(). To model a NATed late joiner, useNodeConfig.port(-1): it starts no listener and publishes signed NodeInfo with empty:transports. Do not callserveAllInbound()on that leaf. Prove reverse propagation with an application write made after the rendezvous node's upgrade future completes; a bootstrap pull alone does not prove that the original outbound socket carries traffic both ways. For Point of Presence routing, configure each outbound-only leaf withpointsOfPresence(relayKey)before launch and opt the public node in withrelayMessages(). Connect both leaves only to that relay, await both inbound upgrade futures on the relay, then complete a destination-side message-handler future fromsendMessageorsendPrivateMessage. Point messages are transient and need no application-rootsync(). Include a wrong-key signature case before a valid message on the same ordered route when testing relay authentication. - After an application write, call the root application's
sync()to publish the authoritative node root. This schedules each propagator independently; its publication filter and serving-store materialisation run on that group's worker. Use the social test's independent filtered groups when testing this boundary:P2PSocialSyncTestretains the ordinary follow-aware P2P group while Bob also serves an infrastructure-only view from another store. A deliberately broken third view proves thatgetStatus()/nextFailure()report degradation while the node and healthy views continue. When batching several edits for one signed social owner, fork theSocialUser, apply its feed and follow actions, sync that fork once, then sync the application root. ASocialfork is outside the owner boundary and therefore still signs each user edit inside the unpublished fork. - Automatic gossip is fire-and-forget. To verify it without sleeping, capture
nextAnnounce()before publishing and re-arm it until the expected application state is present. The announce signals that this propagation group has materialised its served view after the authoritative merge and node-root publication.cursor.sync()itself guarantees only the authoritative node publication; do not assume it completed group fan-out. Use an explicitreceivingServer.pull(group, connection).get(timeout)only when the test is specifically about pull synchronisation. A ping only establishes transport ordering and does not prove acquisition is complete. - Close listeners before nodes, then close their stores.
Host and propagation configuration are deliberately independent. Use
NodeConfig for authoritative persistence and the standard listener, supplying
it independently to each component; passing it to NodeServer never creates a
transport. Use
LatticePropagatorConfig for each group's routes, protocol queue and publication
limits. LatticePropagatorConfig.from(nodeConfig) exists only to migrate old
combined-map callers; do not use it in new tests merely because both objects use
the same underlying CAD map representation.
Treat a node key as its P2P/transport signer, not automatically as an
application user's identity or signing key. For an OwnerLattice keyed by an
indirect owner such as a DID, install a fail-closed owner verifier in the
LatticeContext; without one, indirect owners use the compatibility-lenient
fallback. Give identity-sensitive tests separate node keys and application
owner/signing keys. The social cursor API accepts canonical base DIDs and uses
DIDKeyAuthorizer for the signer binding. Cover did:key, did:convex and
did:web with pinned local state or deterministic resolver fixtures—never depend
on public web resolution in a unit test. serveAllInbound() controls network
access and does not replace DID owner authorisation.
Remember that an AccountKey is a typed JVM view over a canonical 32-byte
Blob. A key stored as ordinary CAD3 application data can therefore decode as a
Blob. Domain readers should parse compatible Blob values with
AccountKey.parse or AccountKey.create; do not use instanceof AccountKey
as a wire-format validity check.
The rules in AGENTS.md apply: never bind fixed ports and never sleep. Wait on
futures, latches or another API whose contract represents the required state.
Stop the network when finished: close listeners before nodes, then close stores.
Signals
- GitHub stars
- 117
- Forks
- 47
- Last commit
- Sep 2026
Advanced
- Catalog kind
- skill
- Gateway key
local-network- Source
- github.com/convex-dev/convex