A provider plugin is the program that supplies machines. The platform runs it as an app on a host, talks to it in newline-delimited JSON-RPC 2.0 over its standard input and output, and asks it to offer capacity, price a machine, create one, and destroy it again. This page is the price half of that contract: what your plugin says a machine costs, when it is asked, and the rules it keeps so that the platform and your infrastructure never disagree about what a running node is held at.
If you are picking a provider rather than writing one, read Providers instead.
Saying that you price your own machines
Some providers know what their machines cost — a cloud with a published plan list — and some do not, and are priced by rates the deployment sets for them. You say which you are with one flag on the offer you publish for a resource:
{
"resource_name": "examplecloud-eu-west-1",
"provider_type": "examplecloud",
"title": "ExampleCloud eu-west-1",
"capacity": {
"region": ["eu-west-1", "us-east-1"],
"plan": ["ec-2c-4gb", "ec-4c-8gb"],
"os": ["ubuntu-24.04-lts-x64", "debian-12-x64"]
},
"quotes": true,
"off_premises": true,
"os_disk_min_gib": 20
}
Every value in capacity is yours to define, and the platform only checks that a spec names one of them. Take an OS id from your cloud's own naming, readable enough to write in a pool's YAML: the Vultr provider slugs Vultr's image name (Ubuntu 24.04 LTS x64 becomes ubuntu-24.04-lts-x64), titles the choice with that name, and keeps Vultr's numeric id for the create. Don't map your cloud's images onto another provider's ids, such as the hypervisor provider's ubuntu:24.04. Offer only the OS families you have validated and can enroll.
quotes is a bare capability flag and nothing else: it says every machine placed on this resource is priced by you before it is allocated. No prices ride on an offer. The currency arrives with each quote and the period with each line, so an offer never carries a number anybody could act on. Leave the flag out — which is what every offer written before this says — and you are never asked for a price. It is per resource, so a provider with one region it can price and one it cannot publishes two offers and is asked about the first only.
Answering a quote
The platform sends provider.quote before it allocates a node, and again whenever somebody is describing a machine in the request wizard. It reserves nothing, holds nothing, and may be repeated at any time — answer it from your cached price list rather than from the network.
{
"resource_uri": "urn:orc:node:n_d0000000000a1",
"spec": {"region": "eu-west-1", "plan": "ec-4c-8gb", "os": "ubuntu-24.04-lts-x64"},
"os_disk_gib": 40,
"app_data_gib": 50
}
A price is the lines it is made of, the currency they are in, and how long you will hold them:
{
"currency": "usd",
"valid_for_seconds": 600,
"lines": [
{"kind": "compute", "label": "4 vCPU, 8 GiB", "unit": "hour", "rate": "0.1920"},
{"kind": "storage", "label": "40 GiB root", "unit": "hour", "rate": "0.0055"},
{"kind": "storage", "label": "50 GiB App data", "unit": "hour", "rate": "0.0068"},
{"kind": "os_license", "label": "Windows Server", "unit": "hour", "rate": "0.0920"}
]
}
No price is lines: null and the reason there is none:
{"lines": null, "reason": "ec-4c-8gb is sold out in eu-west-1 right now"}
The reason is shown to the operator word for word on the pool that is waiting, so write it for a person: what you could not price and why. An empty lines list is read as no price at all, not as a machine that is free.
The rules a line is held to:
rateis an exact decimal string. Rates never travel as binary floating point, and a rate in exponent notation, or a negative one, is not a rate this platform can use. Giving something back is never expressed as a line below zero.unitis the period the rate covers.houris the only one today, and it is what an absentunitmeans.labelis your own display text — "2 vCPU", "80 GiB gp3 root" — and is never parsed. It is stored with every price and shown wherever that price is read, so keep it to 120 characters or fewer.kindis a closed set, because a reader groups and totals by it:
kind | What it is for |
|---|---|
compute | The machine itself. |
os_license | A per-hour operating-system licence you pass on. |
storage | Disk or volume capacity. |
network | Addresses, egress allowances, and other network costs. |
app_license | A licence for software the node runs. |
other | Anything the kinds above do not name. |
A kind or a unit this platform does not know costs that one machine its price: the line cannot be totalled, so nothing is acted on from it. Everything else in the same message still arrives.
usd is the only currency priced today. A quote in anything else is read as unavailable rather than converted, because there is no exchange rate in this protocol and a comparison across currencies would be a guess.
valid_for_seconds is how long you will stand behind the answer. The platform caches the quote for at most that long and then asks again.
Saying which other choices would sell
A quote can also carry "with_availability": true. The platform sends it on every quote it asks, so that the request form can close the choices you could not sell before anyone picks them. When it is set, answer with an availability map beside the price. Each field in your schema maps each of its choices to a verdict. A verdict judges the spec you were asked about with that one field changed to that choice and every other field left as asked:
{
"currency": "usd",
"valid_for_seconds": 900,
"lines": [{"kind": "compute", "label": "Regular 2 vCPU / 4 GiB in New Jersey (ewr)", "unit": "hour", "rate": "0.0297619"}],
"availability": {
"cpu": {"1": {"ok": false, "reason": "no plan in ewr has exactly 1 vCPU and 4 GiB of memory"}, "2": {"ok": true}, "4": {"ok": true}},
"mem": {"1": {"ok": false, "reason": "Windows Server 2022 needs at least 2 GiB of memory"}, "4": {"ok": true}},
"region": {"ewr": {"ok": true}, "nrt": {"ok": false, "reason": "no plan is sold in nrt right now"}}
}
}
- A choice is keyed by the text a form posts for it. A string choice is keyed by the string itself. A number or a boolean is keyed by its JSON text, so the integer choice
2is keyed"2". - Also key the spec's own value of each field, even when it is no longer one of your choices. A pool saved with a region you have since withdrawn is then told why about that region.
- A spec you cannot sell still answers the map:
lines: null, yourreason, andavailability. The map tells a form which neighbouring choice would sell. reasonis shown to tenants beside the choice it closes. Say what they asked for and why it cannot be had. Never name your inventory: no stock levels, and no counts of what is left.- Leave out
availabilitywhen you cannot say. An absent map means nothing is known, and every choice stays open. A plugin built before the flag ignores it, and nothing changes for it. - Answer from your cached price list. The map rides the same cache entry as the price, for the same
valid_for_seconds, and a refusal is held for 30 seconds.
The disks you are quoting for
os_disk_gib is the root disk the platform derived from the spec and the apps the pool runs: their footprints, their snapshot space, and its own headroom. It is what the node needs, not a floor and not a disk. Apply your own minimum on top — what your images will not boot below is your knowledge, not the platform's — and name what you will actually attach in the root line's label. Publish that minimum as os_disk_min_gib on your offer and the platform asks for at least that much from the start, so the number it sends and the disk you create stop drifting apart. An offer that publishes none keeps the fixed floor the platform has always applied, so a plugin that has not been rebuilt never starts receiving smaller disks than it has been building all along.
app_data_gib is the App-data volume the pool asked for — the storage its apps keep across node replacements. 0 is the free tier, which is a small volume, not the absence of one. Price it however you actually back it:
- a
storageline at the rate of the volume you attach; or - a line at rate
0labelled "included", when the instance's own disk holds the App data and you attach nothing.
"Included" means one specific thing: the App data lives on the node's root device and costs nothing more. If your root is itself a volume cut to order, the plan's bundled disk is ephemeral storage that is not the root at all, and calling it included puts the App data on a root sized for the apps alone and fills it. Such a provider has two honest answers — grow the priced root by the App-data size, so the root line covers it, or attach a volume and say so.
Included App data is kept one of two ways on the root device, and the price is the same for both:
- A partition of the root disk. The first-boot document turns cloud-init's root growth off. Before the agent installs, a script grows root to everything but the App-data size and makes a GPT partition named
orc-appdatain the tail, and the App-data hint at/etc/orc/appdata.jsonsayspartition. The agent formats the partition and keeps App data there. Render this layout only when all of these hold: the deploy'senrollment.agent_features, the features of the agent the server's installer hands out, listsapp_data_partition(an older server sends none), the App-data size is above the free tier, and the image's family is Ubuntu, Debian, AlmaLinux or Rocky Linux (can_lay_out_app_data_partitiontakes the family, never an id).orc-provrenders it for you (cloud_init_partition_user_data). - A file on the root filesystem. Root grows to the whole disk and the hint says
file. This is the layout every node gets when the partition conditions do not hold.
The partition layout falls back to the file layout on the node itself. If the first-boot script finds an MBR disk, a root on LVM or not last on the disk, a disk too small for the App data, or a missing tool, or if any step fails or the script is stopped part way, it takes back the partition it appended, grows root to the whole disk through cloud-init's own growpart and resizefs modules (then growpart and the filesystem's tool directly, where cloud-init cannot), and rewrites the hint to file. A second script runs before the installer served at /install-orc-agent.sh starts the service, and again after the install. It runs orc-agent --has-feature app_data_partition, and if the installed agent lacks the feature, it deletes the still-empty partition. If there is no orc-appdata partition at all, whatever the agent, it acts the same way. Either way it grows root to the whole disk, rewrites the hint to file and restarts the agent. The installer carries the same script the first-boot document writes. A partition that already carries a filesystem, or that blkid cannot say is empty, is never touched. Both scripts log to /var/log/orc-appdata-layout.log.
A Windows node keeps included App data the same two ways, but its agent makes the partition itself. Write the hint to C:\etc\orc\appdata.json saying partition, and on first start the agent shrinks C: online by the App-data size and formats the space as an NTFS volume labelled orc-appdata, mounted at a folder with no drive letter. It records the partition by its GUID and reuses it on every later boot. If Windows cannot shrink C: that far, or the shrink would leave C: less than 4 GiB free, the agent keeps the App data in a VHDX on C: instead and stays on it. Write partition only when the deploy's enrollment.agent_features lists app_data_partition_windows. An older Windows agent refuses the layout, so without that feature write file. The Windows installer served at /install-orc-agent.ps1 rewrites a partition hint to file when the agent it installed lacks the feature. Write the hint as ASCII or UTF-8. The agent also reads UTF-8 with a byte-order mark, which is what Windows PowerShell 5.1 writes.
An attached volume is identified by its serial. When you attach one, add the serial the guest sees for it to the hint as serial: {"node_id":"n_…","backing":"disk","serial":"atl-61df8c68f42b49"} (on Vultr, the block's mount_id). A Windows agent finds the volume by this serial and refuses a disk hint without one. It formats a volume that does not carry an App-data filesystem yet, reuses one that does, and never touches the boot disk. Attach a volume to a Windows node only when the deploy lists app_data_volume_serial_windows. A Linux agent does not read the serial yet.
Decide the backing once, when you quote and deploy, and then keep it. Pricing is asked again every time your price list moves; deciding again with different numbers would quietly reprice a running node's volume out of existence.
Deploying at or under the quote
provider.deploy carries the raw lines the node was admitted at, and the currency they were in:
{
"op_id": "...",
"node_id": "n_d0000000000b2",
"spec": {"region": "eu-west-1", "plan": "ec-4c-8gb", "os": "ubuntu-24.04-lts-x64"},
"os_disk_gib": 40,
"app_data_gib": 50,
"currency": "usd",
"quoted_lines": [
{"kind": "compute", "label": "4 vCPU, 8 GiB", "unit": "hour", "rate": "0.1920"}
]
}
Read your own current price and compare it against them before you create anything:
- Above them — refuse. Send
provider.deploy_failedwithprice_changedas the error token and your own sentence as the message ("ec-4c-8gb now 0.2100/h"). The token is what the platform matches on; the sentence is what an operator reads beside it. The node takes its ordinary backoff, the pool asks for a fresh quote, and nothing was bought. - At or under them — deploy. If you are under, send
provider.price_changedafterwards with the lines the node is actually held at.
The comparison is over the sum of the two line sets, not line by line. You are free to re-cut the same total differently — fold a network line into compute, split a disk in two — and only what the machine costs per hour decides whether the deploy may go ahead. Anything you cannot compare is a refusal rather than a deploy: a line that will not total, or an order in a currency you do not price in. A price that was not checked must not become a machine.
There is no price field on a deploy result. A machine that ends up costing something other than its order says so with the notification below, so the platform learns it the same way whether it happens one second after the deploy or a month later.
quoted_lines is absent when the platform priced the node some other way, or did not price it at all. Deploy at whatever your price is; there is nothing to compare against.
Announcing a price change
{
"node_id": "n_d0000000000b2",
"seq": 1757592000000,
"effective_at": "2026-09-11T12:00:00Z",
"lines": [
{"kind": "compute", "label": "4 vCPU, 8 GiB", "unit": "hour", "rate": "0.1840"}
]
}
provider.price_changed is a plain notification: nothing answers it and nothing waits for it. Send one per node whose price moved.
seq is effective_at in unix milliseconds, and it is derived rather than invented. That is the whole point of the rule: you keep no counter, so a restarted plugin that re-reads its price list and dates what it finds still produces a sequence above every one it has sent, with no state of its own to lose. The platform ignores anything at or below the sequence it already holds for that node, so a repeat costs nothing.
Replaying prices when a session starts
provider.sync is the inventory you send at the start of every session: each machine you still hold, and the price in force on it.
{
"vms": [
{
"node_id": "n_d0000000000b2",
"pool_id": "p_d0000000000c3",
"price": {
"seq": 1757592000000,
"effective_at": "2026-09-11T12:00:00Z",
"lines": [{"kind": "compute", "label": "4 vCPU, 8 GiB", "unit": "hour", "rate": "0.1840"}]
}
}
],
"inventory_at_ms": 1757595600000
}
That replay is the durability. There is no outbox to build and no notification to retry: a price change you could not send while the platform was away arrives with the next sync, because the sync carries the price you hold now rather than the changes you made to it.
Sync is authoritative on content. If what you replay differs from what the platform last recorded for a node, the new price is recorded whatever your sequence says — your list is the truth about your own machines. effective_at is optional here, because a plugin that restarted often does not know it; the platform then dates the price at the sync.
price is optional on each machine, so a provider that prices nothing sends exactly the sync it always sent. inventory_at_ms is not about price at all — it is when you read the list — but stamp it: without it, a snapshot taken before a deploy finished reads as evidence that the machine it created is gone.
Capacity that is not on the deployment's network
off_premises: true says the machines this resource creates live somewhere the deployment's own network does not reach — another company's data centre, a region on the far side of the internet. Only you know that; you are the one placing the machine.
The platform needs it for one decision. A machine out there enrolls back over the public internet, and what it is told to reach is a single address. If that address only resolves on one network, the deploy would buy a machine that can never join, and the failure would provision another. So the deploy is held instead of sent: nothing is created, the node keeps the capacity it was allocated, the pool says a reachable server address is what it is waiting for, and setting one starts the work again by itself.
An offer that says nothing is on-premises, which is what every offer written before this says and what all of them are.
Answer what you do not implement
Every request that carries an id is owed an answer. A request for something your plugin does not implement is answered -32601, never dropped: the caller's only other signal is its own timeout, and a timeout is indistinguishable from a plugin that has died. On the price path that is the difference between "ask again in a moment" and "this provider cannot be quoted at all".
The plugin runtime does this for you for any method it routes nowhere, and the default quote implementation does it too — which is the honest answer from a provider that prices nothing. A request whose parameters your build cannot decode is a different answer, -32602: the method is known, the payload is not one you can act on.
Owning your instances
A provider backed by a cloud API keeps no durable record of what it holds. After a restart it finds its machines by asking the cloud, so every instance is tagged with the node and pool it is, and the tags are the inventory:
orc-prod-eu
orc-node:n_d0000000000b2
orc-pool:p_d0000000000c3
The first of those is the owner tag, and it is required configuration with no default. Nothing is guessed for you, because there is no safe value to guess: two installations sharing one cloud account both tag their instances orc-node:…, so a tag they also shared would have each of them claim the other's machines — and a provider that claims a machine the platform does not have on its host has asked for that machine to be destroyed. A plugin started without one refuses to start and says so.
It must not change while the provider holds machines, and nothing stops the change for you. The tag is the only thing that says which machines are yours: a plugin started under a new tag asks the cloud for instances carrying it, finds none, and reports a complete, empty inventory — a teardown order for every machine the installation owns. The teardown looks the machines up by the new tag too, so it finds nothing to delete: the platform loses the nodes while their instances and volumes keep running, and billing, in the cloud account. Give each installation its own tag (orc-prod-eu, orc-staging), and drain the pools a provider serves before changing it.
Nothing is written to disk, so there is no data directory to configure and nothing a restart can lose. A restarted plugin holds nothing and reports nothing until it has listed the whole account for its owner tag; then it reports every instance it found. The first listing walks the whole account rather than trusting a tag filter that may lag a create, and the platform never reads a node deployed in the last few minutes as lost from a report that left it out. Whatever a node's price depends on that the cloud does not store is written into its tags when it is created, beside the node and pool, and read back after a restart:
orc-root-gib:64
orc-app-data:volume:nvme:20
orc-root-gib is the root the node was deployed for, and orc-app-data is where its App data lives: included:<GiB> on the instance's own disk, or volume:<class>:<GiB> on a volume of its own. An instance without them, created before they existed, is adopted from what the cloud reports; where that cannot say everything the price depends on, the node is reported without a price and the platform keeps the one it has. A price a restarted plugin finds again is not announced with provider.price_changed, which the platform records by sequence alone; the sync carries it, and the platform compares a synced price by its lines.
A volume whose instance is gone, destroyed in the cloud's console say, is found the same way. Its label is the owner tag and the node id, orc-prod-eu-n_d0000000000b2, and the inventory pass deletes it when the label names a well-formed node id under exactly this tag, no instance carries that node, no deploy of it is in progress, it is attached to no instance that still exists, and it is more than an hour old.
Offering one resource from several hosts
An offer without a resource_uri is for the host your plugin runs on: the platform fills in that host's node URI, urn:orc:node:<host node id>. Every machine placed on it belongs to that host, and when the host goes (a rolling update of the provider pool, a scale-down, a lost machine) its machines are destroyed with it. A hypervisor's guests do die with it.
A plugin backed by a cloud API can reach its machines from any host it runs on. It offers a shared resource instead, by naming one:
{
"resource_uri": "urn:orc:examplecloud:7c9e6679-7425-40de-944b-e07fc1f90ae7",
"resource_name": "examplecloud-eu-west-1",
"provider_type": "examplecloud",
"capacity": {"region": ["eu-west-1"], "plan": ["ec-2c-4gb"], "os": ["ubuntu-24.04-lts-x64"]}
}
Any URI that does not start with urn:orc:node: names a shared resource. It belongs to the provider pool, so every host of the pool that sends the same URI offers the same resource. It is at most 256 bytes of printable ASCII (! through ~: no spaces, no control characters, nothing outside ASCII), and it must not look like a node URI: anything that starts with urn:orc:node or urn:orc-node in any letter case is refused unless it is exactly urn:orc:node: or urn:orc:node:<your host node id>. A node URI still has to name your own host.
How the platform uses it:
- One host is designated. Deploys, destroys, quotes and resync requests for the resource go to that host alone, and its offer (capacity, schema, terms) is the resource's. The other hosts are standbys: their offers are recorded, and nothing is sent to them.
- The designation stays put while its host is live. When that host is terminating, its link has closed, or it has ended, the designation moves to the live standby with the lowest node id, and later work goes there. A host that comes back does not take it back.
- A departing host takes no machines with it. A host that is terminated withdraws its own offer, and the machines on the resource stay. They are destroyed only when the last host offering the resource goes while its pool winds down (rejected, scaled to zero, or removed). They are destroyed through that host before it ends.
- No live host, no new work. While no host offering the resource is live, nothing is placed on it, and its deploys and destroys wait until one is back.
- Each host revokes only its own offer.
provider.revoke_resourceswith the shared URI withdraws that host's offer. The resource goes when its last host withdraws. - Sync covers what the host offers. A machine on the shared resource that one host's
provider.syncdoes not list is left alone rather than dropped. Only the designated host's prices are recorded.
What your plugin does in return:
-
Stay idempotent by node identity. After a failover, the same deploy can reach another host, and a host restarted mid-deploy is sent it again.
createlooks the node up by itsorc-node:tag before it creates anything, and adopts what it finds.destroyof a machine that is already gone succeeds. -
Keep VXLAN on the host. An offer with
network_mode: vxlancarries one host's VTEP and underlay MTU, so a shared resource with it is refused. Offer the host node instead. -
Fall back when refused, and offer again when told. A new shared resource is accepted only once every server in the deployment runs a version that knows about shared resources. Until then its first offer is answered with error code
-32003, whosedatais{"reason": "shared_resources_unsupported"}(SHARED_RESOURCES_UNSUPPORTEDandSHARED_RESOURCES_UNSUPPORTED_REASONinorc_prov::proto). The same answer comes when the server could not confirm it with the cluster leader just then, and a server from before shared resources answers-32602with a message saying theresource_uri"does not name the host node", and nodata.reason. Any other-32602is a real refusal of your URI. Offer again withoutresource_urimeanwhile, and you get the host-node behaviour above. Once shared resources can be accepted, the host sends your sessionprovider.recipes_changed; on it, offer your shared URI again. Only a URI's first offer is held to this: once the resource exists, offers of it from any host of your pool are accepted.The plugin runtime does all of this for you. When the host refuses a shared offer with either answer (
-32003or its reason, or the older server's own-32602), the runtime sends the same offer again withoutresource_uri, and the session goes on. Onprovider.recipes_changedit runs yourofferagain, which names the shared URI again. Yourofferalways returns the shared URI; it never has to remember that it was refused. Any other refusal still ends the session, with an error in the plugin's log naming the host's message, and so does a fallback the host refuses too.
Naming a cloud's resource
A plugin built on the shared cloud layer (orc-prov-cloud) offers one shared resource per cloud account, and its backend names it in CloudBackend::resource_id:
urn:orc:<platform>:<instance>[:<scope>]
<platform>is the provider type:vultr,ec2.<instance>is the account's own immutable identity, which the cloud assigns and never changes: an account id, a project id, a subscription id. Never a name, a label or an email address, which somebody can change under the provider. Never anything read from the API key, which an operator rotates. Every host of the pool derives it on its own, so it has to come out the same on each of them and stay the same for as long as the account exists.<scope>narrows it when one account holds resources you offer apart (urn:orc:ec2:123456789012:eu-west-1). Leave it out when you offer the whole account.
orc_prov_cloud::resource_uri builds one and refuses anything a host would refuse. Two provider pools with the same account offer the same resource id, and that is allowed: the resource belongs to each pool separately.
A cloud that gives an API key no account id keeps one of its own in the account, in a marker: a small, free object your plugin creates once, finds by name on every start, and never rewrites. Pick an object type that:
- costs nothing and does nothing on its own;
- any key that can manage instances can list, read, create and delete;
- has a name to find it by and free text to carry the id.
The Vultr provider uses a boot startup script named orc-resource-id. It is part of provisioning, so the key that creates instances can manage it, and its content is a script that only exits, carrying the id in a comment. The id is decided once, when no marker exists: the id of the account's root user when the key may list users, a random UUID otherwise. After that it is read and never derived again, so rotating the key to another user of the account keeps the resource.
Two hosts starting at once can both find no marker and both create one. Settle it the same way on every host, from what the cloud lists and nothing a host remembers: keep the marker whose id sorts first, delete the rest, and read the listing again a moment after every create, whichever way its id was decided, so another host's marker is seen. Two hosts need not decide alike: one key may list users and write the root user's id while another host's key cannot and writes a random one. Only a listing read after both creates have landed gives both hosts the same answer.
A resource id your backend cannot read yet (the cloud is down, the key cannot see the marker) is not a reason to offer nothing. The shared layer offers for the host meanwhile, as if the backend named no resource. It asks again once after 5 seconds, then after twice as long each time it fails, up to 5 minutes, and any offer in between asks too. When the id answers, it asks the session for a fresh offer, which names the shared resource.
One instance per node. A failover mid-create can reach a second host before the first host's instance shows up in the cloud's tag filter, and the node ends up with two instances carrying its orc-node: tag. The shared layer's inventory pass keeps one and deletes the others: the one with the node's App-data volume attached, then the older one by the cloud's own creation time, then the smaller instance id. It decides from what the cloud reports and nothing a host remembers: two hosts that each preferred the instance they created would otherwise delete each other's and leave the node with none. A volume is attached to one instance at a time, so the instance that has it holds the node's data; when the cloud lists a volume for the node and neither has it attached, the pass attaches it to the one kept, and tries again on later passes until that works. A node the host is deploying at that moment is left for the next pass. Report Instance::app_data, Instance::app_data_attached and Instance::created_at from the listing so the pass can tell the instances apart and knows a volume is owed; report OwnedVolumes from CloudBackend::list_volumes so it can find a volume whose instance is gone.
Related pages
- Providers — what a provider is to the people who choose one.
- Pools — where a price is shown, and why a pool waits for one.
- Nodes — the machines your plugin supplies, and the states they move through.
- Authoring apps — the app package a provider plugin ships in, and the settings it asks for.