All documentation

Team

The three roles a project membership can hold, what each is checked against, and what adding someone actually does.

Team settings screen showing project members with their roles and a table explaining what each role can do

Membership is per project

A membership row belongs to exactly one project. Adding someone to a project gives them nothing on any other project you or they touch: invite_member takes the project id from the URL and writes it onto the row it creates, and every check elsewhere in the product reads that same project-scoped row, not anything account-wide (api/routers/team.py#invite_member). That is a narrower scope than billing, which is one plan per account pooled across every project you own; see Plans and limits for what sits at the account level instead of the project level.

The three roles

owner, editor and viewer are the only values a membership row's role column can hold, enforced by a database check constraint, ck_project_member_role, on project_members.role, created with the table itself (alembic/versions/46d194c8c504_baseline_schema.py#upgrade). Assigning or reading a fourth value is not possible; the invite and role-change endpoints both reject anything else before it reaches the database (api/routers/team.py#invite_member, #update_member).

What each role can actually do, read from the checks themselves rather than from what the names suggest:

Capability Owner Editor Viewer Checked by
Read this project's data (dashboard, prompts, competitors, citations, articles, optimizations, alerts, integrations, keywords, tags) Yes Yes Yes Creator, or any active membership row of any role (api/dependencies.py#get_project_for_user)
Create, edit or delete that data, or start any run a person can trigger, outside the owner-only rows below Yes Yes No Same check, plus a role read that rejects only viewer (api/dependencies.py#require_write_access)
See the team list Yes Yes Yes Confirms a row exists; does not read its role (api/routers/team.py#list_members)
Add a member, change their role, or take their access away Yes No No api/routers/team.py#invite_member, #update_member, #remove_member
Edit and save the project record, or archive the project Yes No No api/routers/projects.py#update_project, #delete_project
Add or remove a social account Yes No No api/routers/growth_advisor.py#add_social_profile, #delete_social_profile
Download this project's CSV exports Yes Yes Yes Gated by the project owner's plan, not by role; see Plans and limits

Two checks do the work above, not one per capability. Reading is get_project_for_user: it accepts the project's creator and any active membership row regardless of role, so a viewer opens the same screens an editor does. What a viewer sees on those screens is not identical, though: every project payload carries my_role, resolved by this same rule (api/schemas/project.py#my_role, api/routers/projects.py#_with_my_role), and the app reads it to disable, rather than hide, any control a viewer's role would fail on, with one line saying why (frontend/src/lib/permissions.js#canWrite, frontend/src/lib/components/RoleNotice.svelte). Writing narrows that to require_write_access, which calls the same membership check and then rejects the request only if the caller's role reads viewer. That second check is the rule behind the whole write row of the matrix, which is why the row states a rule rather than a list: it is called from fourteen routers, covering articles, brand aliases, citation gaps, competitors, running prompts, Growth Advisor, insights, integrations, keywords, the llms.txt Advisor, optimizations, prompt suggestions, prompts and tags. Running a prompt on demand and asking for more prompt suggestions are both in that set (api/routers/executions.py#execute_single_query, #execute_batch, api/routers/prompt_candidates.py#generate_more_candidates). Everywhere the matrix says "No" for owner-only rows, the check is a third, separate one: the endpoint reads the caller's role directly and accepts nothing but owner. That third check appears in exactly three places: the team endpoints above, saving or archiving the project record, and the two social-account endpoints. Nowhere else in the backend narrows a request to owner alone. Note what "owner" means to that check: it reads owner for the project's creator, and also for anyone holding an active membership row whose role is owner (api/dependencies.py#get_user_role_in_project), so promoting someone to owner really does hand them every one of those endpoints.

Adding someone

The form on this screen is not an invitation. Typing an email address that has no DiscoveredBy account fails outright, with a "User not found" error; invite_member looks the address up before it will create a row (api/routers/team.py#invite_member). Nothing sends that person anything: the function that creates the row does not call an email service, and the product's own outbound-mail functions are six, none of them for a team invite: a sign-in link, a visibility alert, a weekly report, article topics ready, an article ready, and citation gaps ready (api/services/email.py#send_magic_link_email, #send_visibility_alert_email, #send_weekly_report_email, #send_article_topics_ready_email, #send_article_ready_email, #send_citation_gaps_ready_email). invite_member calls none of them.

If the address exists, access is not pending on anything: the row is created active, and the very next request that person makes against this project is checked against it. Adding someone who was on this project before does not create a second row: the endpoint finds their existing one, switches it back on, and overwrites its role with whichever role you picked this time, so re-adding a former editor as a viewer is a demotion of the row they already had rather than a fresh grant (api/routers/team.py#invite_member). The row's accepted_at column exists but nothing in the product ever writes to it, so it stays empty whether or not anyone has "accepted" in any sense a person would recognize. In practice, this makes the order of operations the instruction: the person signs up for their own account first, and only then can you add them.

The owner rules

The project's creator holds a permanent owner row that nothing can touch: update_member and remove_member both compare the row's user id against the project's creator id before doing anything else, and refuse to change its role, deactivate it, or remove it, unconditionally (api/routers/team.py#_project_creator_id, #update_member, #remove_member).

Anyone else holding the owner role can be demoted or removed, but only if doing so would leave at least one other active owner row behind. That count tallies every active row with role = 'owner', and the creator's own row is one of them: a row with that role is written for them when the project is created. So as long as the creator's row is still active, any other owner can be moved down or removed, because the creator's row is what satisfies the remaining-owner count on its own (api/routers/team.py#_count_active_owners, api/routers/projects.py#create_project, #create_onboarding_project).

"Removing" someone is the same operation as pausing them, one layer down. The remove endpoint sets the membership row's active flag to off and commits, which is exactly what pausing through the role-change endpoint does; nothing is deleted either way, and the row keeps its role (api/routers/team.py#remove_member, #update_member). The screen never reaches the remove endpoint at all: its three actions are adding a member, changing a role, and switching a member's access on or off, and the control for the last of those is labelled as pausing and restoring (frontend/src/routes/(app)/settings/team/+page.server.js#actions, frontend/src/routes/(app)/settings/team/+page.svelte#ROLES).

The team-member limit

Adding someone re-runs the plan's team-member check before the row is created, and reactivating a paused member's access runs the same check again. What that check counts is one thing: the project's own active membership rows, the creator's included, since the creator holds a real owner row rather than being added in as an extra (api/services/limits.py#check_team_limit, api/routers/projects.py#create_project). A cap of a given size therefore admits that many people in total, the owner among them, with no seat lost to counting anyone twice. What the actual number is for each plan lives on Pricing, and how the check sits among the other metered limits is Plans and limits's to describe.

Getting into a project you were added to

Everything above is enforced the moment a request carries this project's id. What decides whether that id ever reaches the browser of someone you added is a different call. The project switcher, and the redirect that sends a signed-in user with no projects to onboarding, both come from GET /projects (api/routers/projects.py#list_projects, frontend/src/routes/(app)/+layout.server.js#currentProject).

That endpoint returns a project on either of two grounds: you created it, or you hold an active membership row on it. The two are combined as an OR in a single clause, with the membership half written as a subquery over project ids rather than as a join, so the creator, who also holds their own owner membership row, still comes back exactly once rather than twice (api/routers/projects.py#_accessible_to, #create_project). That is the same definition of access get_project_for_user applies when a request names one project, so the list a person can see and the projects their requests are allowed to reach are the same set.

So someone you add signs in and finds the project in their switcher, whatever role you gave them, and a person whose only projects are ones they were added to is no longer sent to onboarding as though they had none. Loading one project's own record runs through that same clause (api/routers/projects.py#_load_project_with_countries, used by #get_project), so a member can open the project's settings screen too.

Two things follow from that, and they are worth stating plainly. Pausing someone's membership takes the project back out of their switcher at the same moment it takes away their access, because the list and the per-request check read the same active flag. And nothing about this widens what a member can do: the owner-only rows in the matrix above are checked exactly as described, so a member reaching a screen is not the same as being allowed to write from it.

  • Plans and limits: where the team-member check sits among the other metered limits, and why exports ignore role entirely
  • Projects: who can open and save the project record itself, and what pausing or archiving a project does
  • Create your account: how sign-in works for the person you are about to add

Last verified 2026-08-11

Start monitoring your AI visibility.

See how AI search engines talk about your brand.

Free to start. No credit card required.