Skip to content

Making a plugin

A plugin is a normal FiveM resource of your own that asks vMenu to draw a menu on its behalf. You can write one in C#, Lua, JavaScript or TypeScript. All four get the same API, and every code sample on this page is shown in all four. Pick your language once and every sample follows.

Your resource has two halves, and both talk to vMenu over events. Neither side references vMenu’s code.

The client half describes the menu: rows, translations, and which rows are hidden or locked. The server half declares the permissions and settings the server owner controls. Use the same names on both sides and vMenu does all the checking for you.

Language Plugin API Setup
C# NuGet vMenu.Enhanced.ClientAPI and vMenu.Enhanced.ServerAPI C# setup
Lua vmenu.lua, from the plugin-api folder of the vMenu zip Lua setup
JavaScript vmenu.js, from the plugin-api folder of the vMenu zip JavaScript setup
TypeScript npm @vespura/vmenu-plugin TypeScript setup

Every form of the API carries the same version number as the vMenu Enhanced release it belongs to. Use the one matching the vMenu your server runs. Your plugin ships its own copy, so it never depends on vMenu’s files at runtime.

The text on this page uses the C# names. The other languages follow their own conventions:

C# Lua JavaScript and TypeScript
row.Description = x row:SetDescription(x) row.description = x
row.Checked (read) row:GetChecked() row.checked
row.Selected += handler row:OnSelected(handler) row.onSelected(handler)
menu.AddList(text, options, selectedIndex: 1) menu:AddList(text, options, { SelectedIndex = 2 }) menu.addList(text, options, { selectedIndex: 1 })
await plugin.ConnectAsync() plugin:Connect() inside a thread await plugin.connect()
PluginGate.Permission("A") & PluginGate.Setting(s) vMenu.Gate.Permission('A') & vMenu.Gate.Setting(s) Gate.permission('A').and(Gate.setting(s))

Lua counts list positions from 1, like every Lua table. The other three count from 0. Optional arguments are an options table in Lua and an options object in JavaScript, and properties set there ride along with the row when it is added. Event subscriptions in Lua, JavaScript and TypeScript return a function that unsubscribes again.

Client:

using CitizenFX.FiveM.Client;
using CitizenFX.FiveM.Shared.Script;
using vMenu.Enhanced.ClientAPI;
public sealed class Main : IScript
{
public async void Initialize()
{
var plugin = VMenuPlugin.Create("My Plugin");
var greet = plugin.RootMenu.AddButton("Say hello");
greet.Description = "Shows a notification, to prove this thing works.";
greet.Selected += () => plugin.Notify(NotifyStyle.Success, "Hello!");
var enabled = plugin.Settings.Bool("Enabled", true, "Turns my plugin on or off.");
greet.Gate = PluginGate.Permission("Greet") & PluginGate.Setting(enabled);
greet.HideWhenLocked = true;
var result = await plugin.ConnectAsync();
API.Log.Info($"[My Plugin] Registered with vMenu: {result.Accepted}.");
}
}

Server:

using CitizenFX.FiveM.Server;
using CitizenFX.FiveM.Shared.Script;
using vMenu.Enhanced.ServerAPI;
public sealed class Main : IScript
{
public async void Initialize()
{
var declaration = new ServerPluginDeclaration("My Plugin")
.AddPermission("Greet", "Lets someone use the greet button.")
.AddBoolSetting("Enabled", true, "Turns my plugin on or off.");
var result = await VMenuServer.RegisterAsync(declaration);
API.Log.Info($"[My Plugin] Registered with vMenu: {result.Accepted}.");
}
}

The gate is the point of declaring the permission and the setting. Without it the button is always there for everybody and the owner’s config file does nothing. With it, the button only works for a player who has Greet while Enabled is on, and it follows along by itself when either changes. Read a value directly when you want the value rather than a gate: enabled.Value in C#, enabled:GetValue() in Lua, enabled.value in JavaScript and TypeScript.

Connecting and registering never fail with an error. A refusal comes back with Accepted false and a reason in Errors, and anything vMenu accepted but was unhappy about is in Warnings. Both are logged for you too.

Your plugin can start before or after vMenu and registers either way. If vMenu restarts, the plugin API registers everything you declared again. If your plugin restarts, vMenu drops your menus while you are gone and rebuilds them when you return. IsConnected tells you where you stand, and the Disconnected and RegistrationAnswered events fire on the way through.

Everything vMenu’s own menus can do is available:

var menu = plugin.RootMenu;
menu.AddButton("A button");
menu.AddConfirmButton("Something destructive"); // asks for a second press
menu.AddCheckbox("A checkbox", initiallyChecked: true);
menu.AddList("A list", new Text[] { "One", "Two" });
menu.AddConfirmList("A list that asks first", options);
menu.AddSlider("A slider", min: 0, max: 10, position: 5);
menu.AddDynamicList("A list built as you scroll", "start");
menu.AddSeparator("A heading");
var sub = menu.AddSubmenu("A submenu", subtitle: "My Plugin");
sub.Menu.AddButton("A row inside it");

Each returns an object you can change at any time, before or after connecting: Text, Description, Label, LockedDescription, Visible, Enabled, Gate, SetIcons, plus whatever that row type has of its own. Changes after connecting update the live menu.

A checkbox can remember itself between sessions with persist, which stores the choice in your own resource’s key value store. Pass a stable id with it, because automatic ids follow creation order and reordering your code would hand a saved value to the wrong box.

A menu’s subtitle is the bar under the banner. Leave it out and vMenu uses the menu’s title instead, so it is never empty.

A dynamic list asks your code for its next value every time the player scrolls it. Give it a function through ChangeRequested, which gets the current value and whether the player went left, and returns the new value. In Lua that is row:SetChangeRequested(fn), in JavaScript row.changeRequested = fn.

You can connect first and build afterwards. Your row under Plugins appears as soon as your menu has something in it, so a plugin that only contributes player actions never advertises an empty menu.

Everything about a menu can change while players use it. Each change only touches the rows involved, the rest of the menu stays as it is and the highlighted row stays highlighted.

What How (C#)
Add a row at the bottom menu.AddButton(...) and the other Add methods
Add rows at a position using (menu.InsertAt(2)) { menu.AddButton(...); }
Move a row menu.Move(row, 0)
Highlight a row menu.Select(row). Raises Highlighted and IndexChanged like the player moving there.
Remove a row menu.Remove(row). A submenu row takes its menu with it.
Remove every row menu.Clear()
Show only some rows menu.Filter(row => ...), menu.ClearFilter()
Rename a menu menu.Title, menu.Subtitle
Change a row any property, such as Text, Visible or HideWhenLocked

InsertAt takes a function in the other languages, and rows added inside it go in at that position: menu:InsertAt(3, function() ... end) in Lua, menu.insertAt(2, () => ...) in JavaScript.

Filter hides the rows it answers false for. Rows you add later are checked too. Inside a batch that happens when the batch ends, so properties you set right after adding a row count. When the filter looks at something that changed, call Filter again.

Setting a property to the value it already has sends nothing, so you can set everything on every update without worrying about the cost.

Wrap many changes in a batch and vMenu repaints once instead of once per change:

using (plugin.BeginBatch())
{
foreach (var thing in things)
{
menu.AddButton(thing.Name);
}
}

Batches nest safely. Only the outermost one sends, so a helper that batches internally cannot cut your batch short. In JavaScript and TypeScript the batch ends when the function returns, so changes after an await inside it go out on their own.

A menu can have keys of its own. A key only does something while that menu is open, and it shows up as an instructional button, which is the little row of key hints at the bottom right of the screen. This is handy for shortcuts that act on the row the player is standing on:

var edit = menu.AddKey("edit_value", "Type a value", defaultKey: "X", defaultButton: "RUP_INDEX");
edit.Pressed += press =>
{
if (press.Item is PluginList list)
{
// the player pressed X while the cursor was on this list
}
};

Pressed tells you which row the cursor was on. When the player may use that row, you get it as Item. When the row is locked by its gate or switched off with Enabled, Item stays empty and you get the row as DisabledItem instead. That way a key never quietly lets a player use a row they are not allowed to, but you can still decide to react to it yourself, for example to explain why nothing happened. Both are empty when the menu has no rows or the row is not one of yours.

The player can change the key in the game’s own key settings, under vMenu, where it is listed with your plugin’s name in front of it. The game remembers that choice by the key’s id, so pick an id once and never change it, or players lose the key they picked. Ids may only use letters, digits and underscores.

defaultKey is a keyboard key name the way the game writes it, such as X, F5 or LCONTROL. defaultButton is optional and names a controller button, such as RUP_INDEX. Without one, the key still works on a keyboard but shows no hint while the player uses a controller. When your key is one the game already uses for something, pass that game control as shadowedControl and vMenu switches it off while your menu is open, so pressing the key does not also make the character crouch.

Text, Enabled and Gate can change at any time, just like on a row. A disabled key or one whose gate fails does nothing and shows no hint. Keys can be added after connecting too. A key cannot be removed again until the player reconnects.

Every piece of text is either a literal or a key into your own translation tables:

plugin.Translations.Add("en", new Dictionary<string, string>
{
["greet"] = "Greet {name}",
});
menu.AddButton(Text.Key("greet", ("name", Text.Literal("world"))));

A plain string is a literal, so translating is always deliberate. Keys are looked up in the player’s current vMenu language and then in your English table, which is why an en table is required as soon as you provide any. Your keys are yours alone, so they never collide with vMenu’s or another plugin’s. When the player changes language, your menu follows.

Declare both on the server, use them on the client.

// Server
new ServerPluginDeclaration("My Plugin")
.AddPermission("Greet", "Lets someone use the greet button.")
.AddPermission("Poke", "Lets someone poke other players.", staffOnly: true)
.AddBoolSetting("Enabled", true, "Turns my plugin on or off.");
// Client
var enabled = plugin.Settings.Bool("Enabled", true, "Turns my plugin on or off.");
greet.Gate = PluginGate.Permission("Greet") & PluginGate.Setting(enabled);

Gates combine with & and | in C# and Lua, and with and, or, Gate.all and Gate.any in JavaScript. They are evaluated live, and refresh by themselves when the owner changes a permission or setting. Anywhere a gate is expected, a plain permission name works too. HideWhenLocked decides whether a row the player may not use is greyed out with a lock or gone entirely.

Names are short and vMenu scopes them to your resource, so Greet becomes vMenu.Enhanced.Plugins.MyPlugin.Greet. Only letters, digits and underscores are allowed.

Settings come in Bool, Int, Float and String. Declare them on both sides. The server half puts them in the owner’s template, the client half is what you gate on and read from.

For anything that matters, check on the server before you act:

if (VMenuServer.IsPlayerAllowed(source, "Poke")) { ... }

Use this rather than FiveM’s IsPlayerAceAllowed, because it checks parent permissions too. An owner who granted the whole plugin instead of each individual permission then works out of the box.

There is a second version of that check for the case where a refusal means somebody is up to no good:

if (!VMenuServer.RequirePermission(source, "Poke")) { return; }

It answers exactly the same, and it also tells vMenu, which puts a line in the server owner’s security log naming the player and the permission they were missing. Only reach for it when a normal, unmodified client could not possibly have sent you the thing you are handling, which is the usual case for an event behind a menu row you gated. Keep IsPlayerAllowed for everything else, such as deciding which of two things to do, or the owner ends up with a log full of ordinary play and stops reading it.

vMenu sends a player their permissions once and does not send them again on its own, so an ace you add or remove at runtime does not reach a player who already has the menu open. Ask vMenu to send them again from your server code:

VMenuServer.RefreshPermissions(); // every connected player
VMenuServer.RefreshPermissions(playerId); // one player, by their server id
VMenuServer.RefreshPermissions(1, 4, 9); // several players at once

Any resource can do the same without the plugin API by triggering the event on the server, with no ids for everyone or a list of server ids for specific players:

TriggerEvent("vMenu.Enhanced:Permissions:Refresh")
TriggerEvent("vMenu.Enhanced:Permissions:Refresh", { 4, 9 })

A server owner can have vMenu write a line to their Discord log whenever somebody uses one of your rows. It takes both halves of your plugin, and that is on purpose:

// Server
new ServerPluginDeclaration("My Plugin")
.AddLoggedItem("shield", "their shield")
.AddLoggedItem("payout", "paid everybody out");
// Client
var shield = plugin.RootMenu.AddCheckbox("Shield", id: "shield");
shield.Log = true;
var payout = plugin.RootMenu.AddButton("Pay everyone", id: "payout");
payout.Log = true;

Setting Log on its own does nothing. The list your server half declares is the one vMenu actually trusts, because the client half runs on the player’s own machine, and somebody with a modified copy of it could otherwise choose what text appears in the owner’s Discord channel. Declaring it on the server means the worst a modified client can do is replay a row you already said was fine to log. The ids on both sides have to match, so give logged rows an id of your own.

The description is not the row’s name, and for most rows it is not a whole sentence either. vMenu builds the sentence around it, so the shape it needs depends on the kind of row.

A checkbox, list or slider gets wrapped, as in “turned X on”, “set X to 5” or “changed X”. Write those as a thing, with its own “the” or “their” if it wants one, and no verb.

A button is not wrapped at all. Its description becomes the whole sentence, so write those as something the player did, in the past tense.

Get it the wrong way round and the line reads badly, because vMenu adds no words of its own beyond the ones shown above.

• 02/09/2026 21:04:11 Vespura (1) turned their shield on.
plugin: MyPlugin
• 02/09/2026 21:04:19 Vespura (1) paid everybody out.
plugin: MyPlugin

Lines land in the owner’s Actions channel, and they share the same per player allowance as vMenu’s own rows, so a chatty plugin cannot crowd everything else out.

Only interactions that commit to something are logged: pressing a button, confirming a confirm button, ticking a checkbox, and choosing a value on a list, slider or dynamic list. Scrolling through a list and dragging a slider are not, since those fire on every press of an arrow key and would tell the owner nothing except that somebody was browsing.

You can turn Log on and off at any time like any other row property, and none of this needs a newer vMenu than the one you already target. An older vMenu simply ignores the flag and logs nothing.

You never ship a config file. vMenu writes one for the owner out of what you declared, and rewrites it every time your plugin registers. Both files land in vMenu.Enhanced/config/plugins/, named after your resource:

vMenu.Enhanced/config/plugins/MyPlugin.permissions.cfg.example
vMenu.Enhanced/config/plugins/MyPlugin.configuration.cfg.example

The owner copies each file, drops the .example, edits the copy, and execs it from server.cfg. Installing plugins walks them through it.

For your own README:

  • Link to installing plugins, or write your own instructions if yours differ.
  • Tell owners to exec your two files as their own lines rather than pasting your lines into vMenu’s configs. Separate files are what makes your plugin removable in one step later.
  • Tell them your resource name matters, since every permission and both file names are built from it.
  • Give people a way to contact you. We cannot support your plugin, that is your job.

Write a real description for every permission and setting. It becomes the comment above that line in the owner’s generated file, and it may be the only explanation they ever get.

The one place a plugin reaches outside its own submenu is the Plugin Actions submenu inside every player’s page in Online Players:

var poke = plugin.PlayerActions.AddButton("Poke");
poke.Description = "Pokes this player.";
poke.Gate = PluginGate.Permission("Poke");
poke.Selected += target => plugin.Notify(NotifyStyle.Info, $"You poked {target.Name}.");

The same rows serve every player, and the selected one is handed to your callback with their name and server id. vMenu adds a line naming your resource to these descriptions, so players can see which resource added it.

plugin.Notify(NotifyStyle.Success, "That worked.");

Notifications appear in vMenu’s notification area, credited to your plugin. The style is info, success, warning or error, or NotifyStyle.Success and so on in TypeScript, and an optional third argument sets how long it stays, in milliseconds.

GetText opens vMenu’s input box and gives back what the player typed, or nothing when they cancel. Call it from a row’s handler:

var rename = menu.AddButton("Rename vehicle");
rename.Selected += async () =>
{
var name = await plugin.GetTextAsync(
"New name",
maxLength: 32,
initialValue: "My car",
suggestions: [new PromptSuggestion("Daily driver"), new PromptSuggestion("Race car", "For the track")]);
if (name is null)
{
return; // cancelled
}
plugin.Notify(NotifyStyle.Success, $"Renamed to {name}.");
};

All options are optional. maxLength defaults to 60 and vMenu caps it at 500. Suggestions show as a list while the player types, and picking one fills in its value.

To ask several questions in a row, pass them all at once. You get every answer back in order, or nothing if the player cancelled any of them:

var answers = await plugin.GetTextAsync(
new PluginPrompt("First name", maxLength: 20),
new PluginPrompt("Last name", maxLength: 20));
if (answers is [var first, var last]) { ... }

The box refuses, and you get nothing back, when something else is already using it. In Lua these calls wait for the answer, so call them from a menu event handler or a CreateThread, or pass a callback as the last argument.

vMenu can draw its menus in a few different looks, called themes. The server owner picks one for everybody with a convar, and a plugin can put one player in a different one:

plugin.Themes.Changed += () =>
{
foreach (var theme in plugin.Themes.Available)
{
// theme.Id is what you send back, theme.Name is what a player reads,
// theme.IsCurrent says whether it is the one on screen.
}
};
plugin.Themes.Set("dark"); // this player sees the dark theme from now on
plugin.Themes.Reset(); // back to the theme the server picked

vMenu sends the list right after your plugin registers and again on every change, whoever caused it, so build your rows from the Changed event rather than straight after connecting. Available is empty until that first message arrives, and stays empty against a vMenu too old to know about themes.

A theme set this way beats the server’s convar, and it belongs to that one player. vMenu saves nothing, so reconnecting puts the server’s choice back, and Reset does the same without the wait. Remembering a choice is your plugin’s job: keep the id in your own resource’s key value store and set it again on the first list vMenu sends you, which is what the Theme Picker plugin does. No permission and no setting gates any of this, because it changes nothing except what that player sees.

A resource can also hand vMenu new themes, so a server does not have to edit vMenu’s own files to get a look of its own. This one needs no plugin API and no plugin registration at all, it is a single event that any resource can send:

emit(
"vMenu.Enhanced:Plugins:RegisterThemes",
JSON.stringify({
themes: [
{
id: "reddead",
name: "Red Dead",
css: "themes/red-dead.css",
banner: "default",
},
],
}),
);
  • id is what a convar or a plugin names the theme by. Letters, digits, dashes, underscores and dots, and never one of vMenu’s own names.
  • name is what a player reads, falling back to the id.
  • css is a path inside your own resource, or a full https://cfx-nui-<resource>/ url naming another one. vMenu’s menu page loads it from there, so nothing is copied into vMenu. Addresses out on the internet are refused.
  • banner is the picture on top of the menu. One of vMenu’s own, default, gold, green, red, dark, cartoon, or none for the plain Grand Theft Auto one, or a .png, .jpg or .webp of your own, given the same way as the stylesheet. An image needs a MenuAPI new enough to load banners out of another resource, and falls back to vMenu’s default banner otherwise.

Every file the stylesheet uses has to be listed in your fxmanifest.lua under files, or the game has no copy of it to serve. Fonts and images inside the stylesheet are resolved relative to the stylesheet itself.

Send the whole set in one go: a second registration replaces everything that resource registered before, and its themes are dropped the moment it stops. vMenu answers on vMenu.Enhanced:Plugins:<your resource>:ThemesRegistered with accepted, errors and warnings, which is where a refused theme tells you why. Send it once when your resource starts, and again whenever vMenu says vMenu.Enhanced:Plugins:Ready, which is what it says when it restarts.

Once registered, a theme is a theme like any other. It shows up for plugins reading Themes.Available, and the vMenu.Enhanced.MenuAppearance.Skin convar accepts its id. If you would rather not write any of this yourself, the Custom Themes plugin is exactly this, driven by a JSON file you edit.

  • Your resource name is your identity. Permissions, settings and both template files are named after it. Two resources whose names sanitize to the same identity cannot both register, the second is refused.
  • Limits. A menu tree may hold 2000 items and nest 8 levels deep. Rows added after connecting count towards the same 2000. Past that, rows are skipped with a warning. A plugin may declare 25 keys across all its menus.
  • Never trust the client. The client half decides what a menu looks like, nothing more. Anything that changes the world belongs behind a server side permission check.
  • vMenu owns the menu. You describe what you want, vMenu decides how and when it is drawn. That is what keeps your plugin working across vMenu updates.
  • Update with vMenu. A breaking change on vMenu’s side can stop an old plugin from loading.

vMenu and every form of the plugin API are GPL-3.0-or-later. A plugin built on them is a work based on vMenu, so you cannot put a closed license on the result. Your plugin must be licensed GPL-3.0 (or later) or AGPL-3.0 (or later). The GPL explicitly allows being combined with the AGPL, and the AGPL keeps your code open just as well.

A permissive license such as MIT or Apache is not enough on its own. Those let anyone take the code and close it again, which is exactly what vMenu’s license is there to prevent.

  • Running it only on your own servers asks nothing of you. Using software is not distributing it, so your source may stay private.
  • Handing it to anybody else means handing them the source too. Giving it away, selling it, listing it on a store, or sending it to someone running another server are all distribution.
  • You may charge money for it. What you may not do is keep the source to yourself, and whoever paid you is then free to pass both on.

This is a plain language summary, not legal advice. The license text is what counts.