Repository: All-Hands-AI/OpenHands
Pull request: refactor: move session initialization from WebSocket to REST API
Currently the frontend starts a session by connecting to the websocket and then sending an INIT event with initialization data. This PR changes that to use a REST API endpoint instead.

### Changes
- Add POST /api/conversation endpoint for session initialization
- Update frontend to use new endpoint instead of WebSocket INIT event
- Remove WebSocket INIT event handling from backend
- Move all conversation-related routes (e.g. list-files and submit-feedback) to `/api/conversation/{id}/...`
- Main frontend now includes conversation ID in the URL
- Removed all the JWT token encryption logic
  - SIDs (now conversation IDs) are no longer secret
  - ~~This PR CANNOT BE MERGED until https://github.com/All-Hands-AI/OpenHands/pull/5371 is merged, and a new auth mechanism is put in place here, at least for multi-tenant~~
    - Auth mechanism is in place now
  - ~~TBD if we need auth for OSS, since everything is on OSS--would love to hear any opinions here~~
    - I've explicitly mentioned that OSS is meant to be run locally now, with no isolation

Resolves #4281

---

To run this PR locally, use the following command:
```
docker run -it --rm   -p 3000:3000   -v /var/run/docker.sock:/var/run/docker.sock   --add-host host.docker.internal:host-gateway   -e SANDBOX_RUNTIME_CONTAINER_IMAGE=docker.all-hands.dev/all-hands-ai/runtime:9e9e308-nikolaik   --name openhands-app-9e9e308   docker.all-hands.dev/all-hands-ai/openhands:9e9e308
```

Problem statement:
Store session ID as a URI param instead of in-memory
**Motivation**
Storing the token as a URI param will make it much more manageable and easier to access (React Router / Remix has a hook `useSearchParams` that can do just that) throughout the app rather than relying on local storage.

**Technical Design**
When a new session is created, store the ID as a parameter. Retrieving it is as easy as calling a hook within components, and React Router / Remix data loaders already allow for easy access to the URL and params.

**Alternatives to Consider**
Keep it in local storage

**Additional context**
Suggested by @tobitege

Hints from issue discussion:
This issue is stale because it has been open for 30 days with no activity. Remove stale label or comment or this will be closed in 7 days.

Commit message: fix: use useMemo for conversation context value

Patch to review:
diff --git a/frontend/__tests__/components/browser.test.tsx b/frontend/__tests__/components/browser.test.tsx
index eea2eb910a48..48a3acf9ba67 100644
--- a/frontend/__tests__/components/browser.test.tsx
+++ b/frontend/__tests__/components/browser.test.tsx
@@ -1,10 +1,34 @@
+import { describe, it, expect, afterEach, vi } from "vitest";
+import * as router from "react-router";
+
+// Mock useParams before importing components
+vi.mock("react-router", async () => {
+  const actual = await vi.importActual("react-router");
+  return {
+    ...actual as object,
+    useParams: () => ({ conversationId: "test-conversation-id" }),
+  };
+});
+
+// Mock i18next
+vi.mock("react-i18next", () => ({
+  useTranslation: () => ({
+    t: (key: string) => key,
+    i18n: {
+      changeLanguage: () => new Promise(() => {}),
+    },
+  }),
+}));
+
 import { screen } from "@testing-library/react";
-import { describe, it, expect } from "vitest";
 import { renderWithProviders } from "../../test-utils";
 import { BrowserPanel } from "#/components/features/browser/browser";
 
 
 describe("Browser", () => {
+  afterEach(() => {
+    vi.clearAllMocks();
+  });
   it("renders a message if no screenshotSrc is provided", () => {
     renderWithProviders(<BrowserPanel />, {
       preloadedState: {
diff --git a/frontend/__tests__/components/feedback-form.test.tsx b/frontend/__tests__/components/feedback-form.test.tsx
index 4ff87da4534c..c9234e737475 100644
--- a/frontend/__tests__/components/feedback-form.test.tsx
+++ b/frontend/__tests__/components/feedback-form.test.tsx
@@ -1,6 +1,17 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+import * as router from "react-router";
+
+// Mock useParams before importing components
+vi.mock("react-router", async () => {
+  const actual = await vi.importActual("react-router");
+  return {
+    ...actual as object,
+    useParams: () => ({ conversationId: "test-conversation-id" }),
+  };
+});
+
 import { screen } from "@testing-library/react";
 import userEvent from "@testing-library/user-event";
-import { afterEach, describe, expect, it, vi } from "vitest";
 import { renderWithProviders } from "test-utils";
 import { FeedbackForm } from "#/components/features/feedback/feedback-form";
 
diff --git a/frontend/__tests__/routes/_oh.test.tsx b/frontend/__tests__/routes/_oh.test.tsx
index 8897b5dd8bd3..93617920ae35 100644
--- a/frontend/__tests__/routes/_oh.test.tsx
+++ b/frontend/__tests__/routes/_oh.test.tsx
@@ -1,4 +1,5 @@
-import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import * as router from "react-router";
 import { createRoutesStub } from "react-router";
 import { screen, waitFor, within } from "@testing-library/react";
 import { renderWithProviders } from "test-utils";
@@ -18,6 +19,8 @@ describe("frontend/routes/_oh", () => {
     }),
   );
 
+
+
   beforeAll(() => {
     vi.mock("#/utils/user-is-authenticated", () => ({
       userIsAuthenticated: userIsAuthenticatedMock.mockReturnValue(true),
diff --git a/frontend/src/api/open-hands.ts b/frontend/src/api/open-hands.ts
index f3c5c1896905..24501306783e 100644
--- a/frontend/src/api/open-hands.ts
+++ b/frontend/src/api/open-hands.ts
@@ -51,8 +51,12 @@ class OpenHands {
    * @param path Path to list files from
    * @returns List of files available in the given path. If path is not provided, it lists all the files in the workspace
    */
-  static async getFiles(path?: string): Promise<string[]> {
-    const { data } = await openHands.get<string[]>("/api/list-files", {
+  static async getFiles(
+    conversationId: string,
+    path?: string,
+  ): Promise<string[]> {
+    const url = `/api/conversation/${conversationId}/list-files`;
+    const { data } = await openHands.get<string[]>(url, {
       params: { path },
     });
     return data;
@@ -63,8 +67,9 @@ class OpenHands {
    * @param path Full path of the file to retrieve
    * @returns Content of the file
    */
-  static async getFile(path: string): Promise<string> {
-    const { data } = await openHands.get<{ code: string }>("/api/select-file", {
+  static async getFile(conversationId: string, path: string): Promise<string> {
+    const url = `/api/conversation/${conversationId}/select-file`;
+    const { data } = await openHands.get<{ code: string }>(url, {
       params: { file: path },
     });
 
@@ -78,12 +83,14 @@ class OpenHands {
    * @returns Success message or error message
    */
   static async saveFile(
+    conversationId: string,
     path: string,
     content: string,
   ): Promise<SaveFileSuccessResponse> {
+    const url = `/api/conversation/${conversationId}/save-file`;
     const { data } = await openHands.post<
       SaveFileSuccessResponse | ErrorResponse
-    >("/api/save-file", {
+    >(url, {
       filePath: path,
       content,
     });
@@ -97,13 +104,17 @@ class OpenHands {
    * @param file File to upload
    * @returns Success message or error message
    */
-  static async uploadFiles(files: File[]): Promise<FileUploadSuccessResponse> {
+  static async uploadFiles(
+    conversationId: string,
+    files: File[],
+  ): Promise<FileUploadSuccessResponse> {
+    const url = `/api/conversation/${conversationId}/upload-files`;
     const formData = new FormData();
     files.forEach((file) => formData.append("files", file));
 
     const { data } = await openHands.post<
       FileUploadSuccessResponse | ErrorResponse
-    >("/api/upload-files", formData);
+    >(url, formData);
 
     if ("error" in data) throw new Error(data.error);
     return data;
@@ -114,11 +125,12 @@ class OpenHands {
    * @param data Feedback data
    * @returns The stored feedback data
    */
-  static async submitFeedback(feedback: Feedback): Promise<FeedbackResponse> {
-    const { data } = await openHands.post<FeedbackResponse>(
-      "/api/submit-feedback",
-      feedback,
-    );
+  static async submitFeedback(
+    conversationId: string,
+    feedback: Feedback,
+  ): Promise<FeedbackResponse> {
+    const url = `/api/conversation/${conversationId}/submit-feedback`;
+    const { data } = await openHands.post<FeedbackResponse>(url, feedback);
     return data;
   }
 
@@ -140,8 +152,9 @@ class OpenHands {
    * Get the blob of the workspace zip
    * @returns Blob of the workspace zip
    */
-  static async getWorkspaceZip(): Promise<Blob> {
-    const response = await openHands.get("/api/zip-directory", {
+  static async getWorkspaceZip(conversationId: string): Promise<Blob> {
+    const url = `/api/conversation/${conversationId}/zip-directory`;
+    const response = await openHands.get(url, {
       responseType: "blob",
     });
     return response.data;
@@ -167,18 +180,65 @@ class OpenHands {
    * Get the VSCode URL
    * @returns VSCode URL
    */
-  static async getVSCodeUrl(): Promise<GetVSCodeUrlResponse> {
-    const { data } =
-      await openHands.get<GetVSCodeUrlResponse>("/api/vscode-url");
+  static async getVSCodeUrl(convoId: string): Promise<GetVSCodeUrlResponse> {
+    const { data } = await openHands.get<GetVSCodeUrlResponse>(
+      `/api/conversation/${convoId}/vscode-url`,
+    );
     return data;
   }
 
-  static async getRuntimeId(): Promise<{ runtime_id: string }> {
+  static async getRuntimeId(convoId: string): Promise<{ runtime_id: string }> {
     const { data } = await openHands.get<{ runtime_id: string }>(
-      "/api/conversation",
+      `/api/conversation/${convoId}/config`,
     );
     return data;
   }
+
+  static async searchEvents(
+    conversationId: string,
+    params: {
+      query?: string;
+      startId?: number;
+      limit?: number;
+      eventType?: string;
+      source?: string;
+      startDate?: string;
+      endDate?: string;
+    },
+  ): Promise<{ events: Record<string, unknown>[]; has_more: boolean }> {
+    const { data } = await openHands.get<{
+      events: Record<string, unknown>[];
+      has_more: boolean;
+    }>(`/api/conversation/${conversationId}/events/search`, {
+      params: {
+        query: params.query,
+        start_id: params.startId,
+        limit: params.limit,
+        event_type: params.eventType,
+        source: params.source,
+        start_date: params.startDate,
+        end_date: params.endDate,
+      },
+    });
+    return data;
+  }
+
+  static async initSession(params: {
+    githubToken?: string;
+    args?: Record<string, unknown>;
+    selectedRepository?: string;
+  }): Promise<{ token: string; status: string; conversation_id: string }> {
+    const { data } = await openHands.post<{
+      token: string;
+      status: string;
+      conversation_id: string;
+    }>("/api/conversation", {
+      github_token: params.githubToken,
+      args: params.args,
+      selected_repository: params.selectedRepository,
+    });
+    return data;
+  }
 }
 
 export default OpenHands;
diff --git a/frontend/src/components/features/chat/action-suggestions.tsx b/frontend/src/components/features/chat/action-suggestions.tsx
index 28feab537b83..c786628037dc 100644
--- a/frontend/src/components/features/chat/action-suggestions.tsx
+++ b/frontend/src/components/features/chat/action-suggestions.tsx
@@ -2,6 +2,7 @@ import posthog from "posthog-js";
 import React from "react";
 import { SuggestionItem } from "#/components/features/suggestions/suggestion-item";
 import { useAuth } from "#/context/auth-context";
+import { useConversation } from "#/context/conversation-context";
 import { downloadWorkspace } from "#/utils/download-workspace";
 
 interface ActionSuggestionsProps {
@@ -12,6 +13,7 @@ export function ActionSuggestions({
   onSuggestionsClick,
 }: ActionSuggestionsProps) {
   const { gitHubToken } = useAuth();
+  const { conversationId } = useConversation();
 
   const [isDownloading, setIsDownloading] = React.useState(false);
   const [hasPullRequest, setHasPullRequest] = React.useState(false);
@@ -19,7 +21,7 @@ export function ActionSuggestions({
   const handleDownloadWorkspace = async () => {
     setIsDownloading(true);
     try {
-      await downloadWorkspace();
+      await downloadWorkspace(conversationId);
     } catch (error) {
       // TODO: Handle error
     } finally {
diff --git a/frontend/src/components/features/project-menu/ProjectMenuCard.tsx b/frontend/src/components/features/project-menu/ProjectMenuCard.tsx
index b8fd755a6050..079997c7c872 100644
--- a/frontend/src/components/features/project-menu/ProjectMenuCard.tsx
+++ b/frontend/src/components/features/project-menu/ProjectMenuCard.tsx
@@ -10,6 +10,7 @@ import { ProjectMenuDetailsPlaceholder } from "./project-menu-details-placeholde
 import { ProjectMenuDetails } from "./project-menu-details";
 import { downloadWorkspace } from "#/utils/download-workspace";
 import { useWsClient } from "#/context/ws-client-provider";
+import { useConversation } from "#/context/conversation-context";
 import { LoadingSpinner } from "#/components/shared/loading-spinner";
 import { ConnectToGitHubModal } from "#/components/shared/modals/connect-to-github-modal";
 import { ModalBackdrop } from "#/components/shared/modals/modal-backdrop";
@@ -28,6 +29,7 @@ export function ProjectMenuCard({
   githubData,
 }: ProjectMenuCardProps) {
   const { send } = useWsClient();
+  const { conversationId } = useConversation();
   const dispatch = useDispatch();
 
   const [contextMenuIsOpen, setContextMenuIsOpen] = React.useState(false);
@@ -64,7 +66,7 @@ Please push the changes to GitHub and open a pull request.
     posthog.capture("download_workspace_button_clicked");
     try {
       setWorking(true);
-      downloadWorkspace().then(
+      downloadWorkspace(conversationId).then(
         () => setWorking(false),
         () => setWorking(false),
       );
diff --git a/frontend/src/components/features/sidebar/sidebar.tsx b/frontend/src/components/features/sidebar/sidebar.tsx
index 468c5c156882..b2b4682d3346 100644
--- a/frontend/src/components/features/sidebar/sidebar.tsx
+++ b/frontend/src/components/features/sidebar/sidebar.tsx
@@ -45,7 +45,7 @@ export function Sidebar() {
   };
 
   const handleClickLogo = () => {
-    if (location.pathname.startsWith("/app"))
+    if (location.pathname.startsWith("/conversation/"))
       setStartNewProjectModalIsOpen(true);
   };
 
diff --git a/frontend/src/components/shared/modals/settings/settings-form.tsx b/frontend/src/components/shared/modals/settings/settings-form.tsx
index f6063ce68c98..32f50f8f3688 100644
--- a/frontend/src/components/shared/modals/settings/settings-form.tsx
+++ b/frontend/src/components/shared/modals/settings/settings-form.tsx
@@ -85,7 +85,7 @@ export function SettingsForm({
   const [showWarningModal, setShowWarningModal] = React.useState(false);
 
   const resetOngoingSession = () => {
-    if (location.pathname.startsWith("/app")) {
+    if (location.pathname.startsWith("/conversation/")) {
       endSession();
       onClose();
     }
@@ -127,7 +127,7 @@ export function SettingsForm({
 
     if (!apiKey) {
       setShowWarningModal(true);
-    } else if (location.pathname.startsWith("/app")) {
+    } else if (location.pathname.startsWith("/conversation/")) {
       setConfirmEndSessionModalOpen(true);
     } else {
       handleFormSubmission(formData);
diff --git a/frontend/src/components/shared/task-form.tsx b/frontend/src/components/shared/task-form.tsx
index 786e6db7b46a..147e73571881 100644
--- a/frontend/src/components/shared/task-form.tsx
+++ b/frontend/src/components/shared/task-form.tsx
@@ -8,6 +8,10 @@ import {
   removeFile,
   setInitialQuery,
 } from "#/state/initial-query-slice";
+import OpenHands from "#/api/open-hands";
+import { useAuth } from "#/context/auth-context";
+import { useUserPrefs } from "#/context/user-prefs-context";
+
 import { SuggestionBubble } from "#/components/features/suggestions/suggestion-bubble";
 import { SUGGESTIONS } from "#/utils/suggestions";
 import { convertImageToBase64 } from "#/utils/convert-image-to-base-64";
@@ -22,6 +26,8 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
   const dispatch = useDispatch();
   const navigation = useNavigation();
   const navigate = useNavigate();
+  const { gitHubToken } = useAuth();
+  const { settings } = useUserPrefs();
 
   const { selectedRepository, files } = useSelector(
     (state: RootState) => state.initalQuery,
@@ -32,6 +38,7 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
     getRandomKey(SUGGESTIONS["non-repo"]),
   );
   const [inputIsFocused, setInputIsFocused] = React.useState(false);
+  const [isInitializing, setIsInitializing] = React.useState(false);
 
   const onRefreshSuggestion = () => {
     const suggestions = SUGGESTIONS["non-repo"];
@@ -57,21 +64,37 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
     return "What do you want to build?";
   }, [selectedRepository]);
 
-  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
+  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
     event.preventDefault();
     const formData = new FormData(event.currentTarget);
 
     const q = formData.get("q")?.toString();
-    if (q) dispatch(setInitialQuery(q));
+    if (!q) return;
+
+    dispatch(setInitialQuery(q));
 
-    posthog.capture("initial_query_submitted", {
-      entry_point: "task_form",
-      query_character_length: q?.length,
-      has_repository: !!selectedRepository,
-      has_files: files.length > 0,
-    });
+    try {
+      setIsInitializing(true);
+      // Initialize the session before navigating
+      const { conversation_id: conversationId } = await OpenHands.initSession({
+        githubToken: gitHubToken || undefined,
+        selectedRepository: selectedRepository || undefined,
+        args: settings || undefined,
+      });
 
-    navigate("/app");
+      posthog.capture("initial_query_submitted", {
+        entry_point: "task_form",
+        query_character_length: q.length,
+        has_repository: !!selectedRepository,
+        has_files: files.length > 0,
+      });
+
+      navigate(`/conversation/${conversationId}`);
+    } catch (error) {
+      // TODO: Show error toast
+    } finally {
+      setIsInitializing(false);
+    }
   };
 
   return (
@@ -114,7 +137,7 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
             showButton={!!text}
             className="text-[17px] leading-5 py-[17px]"
             buttonClassName="pb-[17px]"
-            disabled={navigation.state === "submitting"}
+            disabled={navigation.state === "submitting" || isInitializing}
           />
         </div>
       </form>
diff --git a/frontend/src/context/conversation-context.tsx b/frontend/src/context/conversation-context.tsx
new file mode 100644
index 000000000000..748ee8ea0ac0
--- /dev/null
+++ b/frontend/src/context/conversation-context.tsx
@@ -0,0 +1,42 @@
+import React, { useMemo } from "react";
+import { useParams } from "react-router";
+
+interface ConversationContextType {
+  conversationId: string;
+}
+
+const ConversationContext = React.createContext<ConversationContextType | null>(
+  null,
+);
+
+export function ConversationProvider({
+  children,
+}: {
+  children: React.ReactNode;
+}) {
+  const { conversationId } = useParams<{ conversationId: string }>();
+
+  if (!conversationId) {
+    throw new Error(
+      "ConversationProvider must be used within a route that has a conversationId parameter",
+    );
+  }
+
+  const value = useMemo(() => ({ conversationId }), [conversationId]);
+
+  return (
+    <ConversationContext.Provider value={value}>
+      {children}
+    </ConversationContext.Provider>
+  );
+}
+
+export function useConversation() {
+  const context = React.useContext(ConversationContext);
+  if (!context) {
+    throw new Error(
+      "useConversation must be used within a ConversationProvider",
+    );
+  }
+  return context;
+}
diff --git a/frontend/src/context/ws-client-provider.tsx b/frontend/src/context/ws-client-provider.tsx
index 36028d89c169..08f2090c893d 100644
--- a/frontend/src/context/ws-client-provider.tsx
+++ b/frontend/src/context/ws-client-provider.tsx
@@ -1,8 +1,7 @@
 import posthog from "posthog-js";
 import React from "react";
 import { io, Socket } from "socket.io-client";
-import { Settings } from "#/services/settings";
-import ActionType from "#/types/action-type";
+
 import EventLogger from "#/utils/event-logger";
 import AgentState from "#/types/agent-state";
 import { handleAssistantMessage } from "#/services/actions";
@@ -36,22 +35,19 @@ const WsClientContext = React.createContext<UseWsClient>({
 
 interface WsClientProviderProps {
   enabled: boolean;
-  token: string | null;
+  conversationId: string;
   ghToken: string | null;
   selectedRepository: string | null;
-  settings: Settings | null;
 }
 
 export function WsClientProvider({
   enabled,
-  token,
   ghToken,
   selectedRepository,
-  settings,
+  conversationId,
   children,
 }: React.PropsWithChildren<WsClientProviderProps>) {
   const sioRef = React.useRef<Socket | null>(null);
-  const tokenRef = React.useRef<string | null>(token);
   const ghTokenRef = React.useRef<string | null>(ghToken);
   const selectedRepositoryRef = React.useRef<string | null>(selectedRepository);
   const disconnectRef = React.useRef<ReturnType<typeof setTimeout> | null>(
@@ -73,25 +69,6 @@ export function WsClientProvider({
 
   function handleConnect() {
     setStatus(WsClientProviderStatus.OPENING);
-
-    const initEvent: Record<string, unknown> = {
-      action: ActionType.INIT,
-      args: settings,
-    };
-    if (token) {
-      initEvent.token = token;
-    }
-    if (ghToken) {
-      initEvent.github_token = ghToken;
-    }
-    if (selectedRepository) {
-      initEvent.selected_repository = selectedRepository;
-    }
-    const lastEvent = lastEventRef.current;
-    if (lastEvent) {
-      initEvent.latest_event_id = lastEvent.id;
-    }
-    send(initEvent);
   }
 
   function handleMessage(event: Record<string, unknown>) {
@@ -114,9 +91,7 @@ export function WsClientProvider({
       return;
     }
 
-    if (!event.token) {
-      handleAssistantMessage(event);
-    }
+    handleAssistantMessage(event);
   }
 
   function handleDisconnect() {
@@ -128,11 +103,13 @@ export function WsClientProvider({
     setStatus(WsClientProviderStatus.ERROR);
   }
 
-  // Connect websocket
   React.useEffect(() => {
+    if (!conversationId) {
+      throw new Error("No conversation ID provided");
+    }
+
     let sio = sioRef.current;
 
-    // If disabled disconnect any existing websockets...
     if (!enabled) {
       if (sio) {
         sio.disconnect();
@@ -140,21 +117,22 @@ export function WsClientProvider({
       return () => {};
     }
 
-    // If there is no websocket or the tokens have changed or the current websocket is disconnected,
-    // create a new one
-    if (
-      !sio ||
-      (tokenRef.current && token && token !== tokenRef.current) ||
-      ghToken !== ghTokenRef.current
-    ) {
-      sio?.disconnect();
+    const lastEvent = lastEventRef.current;
+    const query = {
+      latest_event_id: lastEvent?.id ?? -1,
+      conversation_id: conversationId,
+    };
 
-      const baseUrl =
-        import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
-      sio = io(baseUrl, {
-        transports: ["websocket"],
-      });
-    }
+    const baseUrl =
+      import.meta.env.VITE_BACKEND_BASE_URL || window?.location.host;
+
+    sio = io(baseUrl, {
+      transports: ["websocket"],
+      auth: {
+        githubToken: ghToken || undefined,
+      },
+      query,
+    });
     sio.on("connect", handleConnect);
     sio.on("oh_event", handleMessage);
     sio.on("connect_error", handleError);
@@ -162,7 +140,6 @@ export function WsClientProvider({
     sio.on("disconnect", handleDisconnect);
 
     sioRef.current = sio;
-    tokenRef.current = token;
     ghTokenRef.current = ghToken;
     selectedRepositoryRef.current = selectedRepository;
 
@@ -173,7 +150,7 @@ export function WsClientProvider({
       sio.off("connect_failed", handleError);
       sio.off("disconnect", handleDisconnect);
     };
-  }, [enabled, token, ghToken, selectedRepository]);
+  }, [enabled, ghToken, selectedRepository]);
 
   // Strict mode mounts and unmounts each component twice, so we have to wait in the destructor
   // before actually disconnecting the socket and cancel the operation if the component gets remounted.
diff --git a/frontend/src/hooks/mutation/use-save-file.ts b/frontend/src/hooks/mutation/use-save-file.ts
index b62d86ff7108..709b9e7489d8 100644
--- a/frontend/src/hooks/mutation/use-save-file.ts
+++ b/frontend/src/hooks/mutation/use-save-file.ts
@@ -1,17 +1,20 @@
 import { useMutation } from "@tanstack/react-query";
 import toast from "react-hot-toast";
 import OpenHands from "#/api/open-hands";
+import { useConversation } from "#/context/conversation-context";
 
 type SaveFileArgs = {
   path: string;
   content: string;
 };
 
-export const useSaveFile = () =>
-  useMutation({
+export const useSaveFile = () => {
+  const { conversationId } = useConversation();
+  return useMutation({
     mutationFn: ({ path, content }: SaveFileArgs) =>
-      OpenHands.saveFile(path, content),
+      OpenHands.saveFile(conversationId, path, content),
     onError: (error) => {
       toast.error(error.message);
     },
   });
+};
diff --git a/frontend/src/hooks/mutation/use-submit-feedback.ts b/frontend/src/hooks/mutation/use-submit-feedback.ts
index 6948adc214f4..5ecf68e32cfc 100644
--- a/frontend/src/hooks/mutation/use-submit-feedback.ts
+++ b/frontend/src/hooks/mutation/use-submit-feedback.ts
@@ -2,16 +2,19 @@ import { useMutation } from "@tanstack/react-query";
 import toast from "react-hot-toast";
 import { Feedback } from "#/api/open-hands.types";
 import OpenHands from "#/api/open-hands";
+import { useConversation } from "#/context/conversation-context";
 
 type SubmitFeedbackArgs = {
   feedback: Feedback;
 };
 
-export const useSubmitFeedback = () =>
-  useMutation({
+export const useSubmitFeedback = () => {
+  const { conversationId } = useConversation();
+  return useMutation({
     mutationFn: ({ feedback }: SubmitFeedbackArgs) =>
-      OpenHands.submitFeedback(feedback),
+      OpenHands.submitFeedback(conversationId, feedback),
     onError: (error) => {
       toast.error(error.message);
     },
   });
+};
diff --git a/frontend/src/hooks/mutation/use-upload-files.ts b/frontend/src/hooks/mutation/use-upload-files.ts
index c10886c15019..5c8635e7365e 100644
--- a/frontend/src/hooks/mutation/use-upload-files.ts
+++ b/frontend/src/hooks/mutation/use-upload-files.ts
@@ -1,11 +1,15 @@
 import { useMutation } from "@tanstack/react-query";
 import OpenHands from "#/api/open-hands";
+import { useConversation } from "#/context/conversation-context";
 
 type UploadFilesArgs = {
   files: File[];
 };
 
-export const useUploadFiles = () =>
-  useMutation({
-    mutationFn: ({ files }: UploadFilesArgs) => OpenHands.uploadFiles(files),
+export const useUploadFiles = () => {
+  const { conversationId } = useConversation();
+  return useMutation({
+    mutationFn: ({ files }: UploadFilesArgs) =>
+      OpenHands.uploadFiles(conversationId, files),
   });
+};
diff --git a/frontend/src/hooks/query/use-conversation-config.ts b/frontend/src/hooks/query/use-conversation-config.ts
index 729c6c969d13..fb74f25f50d9 100644
--- a/frontend/src/hooks/query/use-conversation-config.ts
+++ b/frontend/src/hooks/query/use-conversation-config.ts
@@ -4,15 +4,20 @@ import {
   useWsClient,
   WsClientProviderStatus,
 } from "#/context/ws-client-provider";
+import { useConversation } from "#/context/conversation-context";
 import OpenHands from "#/api/open-hands";
 
 export const useConversationConfig = () => {
   const { status } = useWsClient();
+  const { conversationId } = useConversation();
 
   const query = useQuery({
-    queryKey: ["conversation_config"],
-    queryFn: OpenHands.getRuntimeId,
-    enabled: status === WsClientProviderStatus.ACTIVE,
+    queryKey: ["conversation_config", conversationId],
+    queryFn: () => {
+      if (!conversationId) throw new Error("No conversation ID");
+      return OpenHands.getRuntimeId(conversationId);
+    },
+    enabled: status === WsClientProviderStatus.ACTIVE && !!conversationId,
   });
 
   React.useEffect(() => {
diff --git a/frontend/src/hooks/query/use-list-file.ts b/frontend/src/hooks/query/use-list-file.ts
index 7a498e4b8856..0cedf98fae5b 100644
--- a/frontend/src/hooks/query/use-list-file.ts
+++ b/frontend/src/hooks/query/use-list-file.ts
@@ -1,13 +1,16 @@
 import { useQuery } from "@tanstack/react-query";
 import OpenHands from "#/api/open-hands";
+import { useConversation } from "#/context/conversation-context";
 
 interface UseListFileConfig {
   path: string;
 }
 
-export const useListFile = (config: UseListFileConfig) =>
-  useQuery({
-    queryKey: ["file", config.path],
-    queryFn: () => OpenHands.getFile(config.path),
+export const useListFile = (config: UseListFileConfig) => {
+  const { conversationId } = useConversation();
+  return useQuery({
+    queryKey: ["file", conversationId, config.path],
+    queryFn: () => OpenHands.getFile(conversationId, config.path),
     enabled: false, // don't fetch by default, trigger manually via `refetch`
   });
+};
diff --git a/frontend/src/hooks/query/use-list-files.ts b/frontend/src/hooks/query/use-list-files.ts
index 1a0fa1f10371..66b9806f0900 100644
--- a/frontend/src/hooks/query/use-list-files.ts
+++ b/frontend/src/hooks/query/use-list-files.ts
@@ -4,6 +4,7 @@ import {
   WsClientProviderStatus,
 } from "#/context/ws-client-provider";
 import OpenHands from "#/api/open-hands";
+import { useConversation } from "#/context/conversation-context";
 import { useAuth } from "#/context/auth-context";
 
 interface UseListFilesConfig {
@@ -13,12 +14,13 @@ interface UseListFilesConfig {
 
 export const useListFiles = (config?: UseListFilesConfig) => {
   const { token } = useAuth();
+  const { conversationId } = useConversation();
   const { status } = useWsClient();
   const isActive = status === WsClientProviderStatus.ACTIVE;
 
   return useQuery({
-    queryKey: ["files", token, config?.path],
-    queryFn: () => OpenHands.getFiles(config?.path),
+    queryKey: ["files", token, conversationId, config?.path],
+    queryFn: () => OpenHands.getFiles(conversationId, config?.path),
     enabled: !!(isActive && config?.enabled && token),
   });
 };
diff --git a/frontend/src/hooks/query/use-search-events.ts b/frontend/src/hooks/query/use-search-events.ts
new file mode 100644
index 000000000000..2b18c2c9818c
--- /dev/null
+++ b/frontend/src/hooks/query/use-search-events.ts
@@ -0,0 +1,24 @@
+import { useQuery } from "@tanstack/react-query";
+import { useConversation } from "#/context/conversation-context";
+import OpenHands from "#/api/open-hands";
+
+export const useSearchEvents = (params: {
+  query?: string;
+  startId?: number;
+  limit?: number;
+  eventType?: string;
+  source?: string;
+  startDate?: string;
+  endDate?: string;
+}) => {
+  const { conversationId } = useConversation();
+
+  return useQuery({
+    queryKey: ["search_events", conversationId, params],
+    queryFn: () => {
+      if (!conversationId) throw new Error("No conversation ID");
+      return OpenHands.searchEvents(conversationId, params);
+    },
+    enabled: !!conversationId,
+  });
+};
diff --git a/frontend/src/hooks/query/use-vscode-url.ts b/frontend/src/hooks/query/use-vscode-url.ts
index 9c913e57cf4b..2a314d304437 100644
--- a/frontend/src/hooks/query/use-vscode-url.ts
+++ b/frontend/src/hooks/query/use-vscode-url.ts
@@ -6,14 +6,19 @@ import toast from "#/utils/toast";
 import { addAssistantMessage } from "#/state/chat-slice";
 import { I18nKey } from "#/i18n/declaration";
 import OpenHands from "#/api/open-hands";
+import { useConversation } from "#/context/conversation-context";
 
 export const useVSCodeUrl = () => {
   const { t } = useTranslation();
   const dispatch = useDispatch();
+  const { conversationId } = useConversation();
 
   const data = useQuery({
-    queryKey: ["vscode_url"],
-    queryFn: OpenHands.getVSCodeUrl,
+    queryKey: ["vscode_url", conversationId],
+    queryFn: () => {
+      if (!conversationId) throw new Error("No conversation ID");
+      return OpenHands.getVSCodeUrl(conversationId);
+    },
     enabled: false,
   });
 
diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts
new file mode 100644
index 000000000000..a342a82354a7
--- /dev/null
+++ b/frontend/src/i18n/en.ts
@@ -0,0 +1,5 @@
+export const en = {
+  BROWSER: {
+    EMPTY_MESSAGE: "No browser content to display yet",
+  },
+};
diff --git a/frontend/src/routes.ts b/frontend/src/routes.ts
index 1b59fc6b1769..7791baca365c 100644
--- a/frontend/src/routes.ts
+++ b/frontend/src/routes.ts
@@ -8,7 +8,7 @@ import {
 export default [
   layout("routes/_oh/route.tsx", [
     index("routes/_oh._index/route.tsx"),
-    route("app", "routes/_oh.app/route.tsx", [
+    route("conversation/:conversationId", "routes/_oh.app/route.tsx", [
       index("routes/_oh.app._index/route.tsx"),
       route("browser", "routes/_oh.app.browser.tsx"),
       route("jupyter", "routes/_oh.app.jupyter.tsx"),
diff --git a/frontend/src/routes/_oh._index/route.tsx b/frontend/src/routes/_oh._index/route.tsx
index 60c5954aaaf8..e7c53748269b 100644
--- a/frontend/src/routes/_oh._index/route.tsx
+++ b/frontend/src/routes/_oh._index/route.tsx
@@ -1,4 +1,4 @@
-import { useLocation, useNavigate } from "react-router";
+import { useLocation } from "react-router";
 import React from "react";
 import { useDispatch } from "react-redux";
 import posthog from "posthog-js";
@@ -15,11 +15,9 @@ import { HeroHeading } from "#/components/shared/hero-heading";
 import { TaskForm } from "#/components/shared/task-form";
 
 function Home() {
-  const { token, gitHubToken } = useAuth();
-
+  const { gitHubToken } = useAuth();
   const dispatch = useDispatch();
   const location = useLocation();
-  const navigate = useNavigate();
 
   const formRef = React.useRef<HTMLFormElement>(null);
 
@@ -34,7 +32,8 @@ function Home() {
   });
 
   React.useEffect(() => {
-    if (token) navigate("/app");
+    // If we have a token but no conversation ID, we need to initialize a new session
+    // This will be handled by the TaskForm
   }, [location.pathname]);
 
   return (
diff --git a/frontend/src/routes/_oh.app/route.tsx b/frontend/src/routes/_oh.app/route.tsx
index 2a8e5f07ec98..2ffffd393559 100644
--- a/frontend/src/routes/_oh.app/route.tsx
+++ b/frontend/src/routes/_oh.app/route.tsx
@@ -2,6 +2,10 @@ import { useDisclosure } from "@nextui-org/react";
 import React from "react";
 import { Outlet } from "react-router";
 import { useDispatch, useSelector } from "react-redux";
+import {
+  ConversationProvider,
+  useConversation,
+} from "#/context/conversation-context";
 import { Controls } from "#/components/features/controls/controls";
 import { RootState } from "#/store";
 import { clearMessages } from "#/state/chat-slice";
@@ -22,9 +26,10 @@ import { useConversationConfig } from "#/hooks/query/use-conversation-config";
 import { Container } from "#/components/layout/container";
 import Security from "#/components/shared/modals/security/security";
 
-function App() {
-  const { token, gitHubToken } = useAuth();
+function AppContent() {
+  const { gitHubToken } = useAuth();
   const { settings } = useUserPrefs();
+  const { conversationId } = useConversation();
 
   const dispatch = useDispatch();
   useConversationConfig();
@@ -38,8 +43,8 @@ function App() {
   });
 
   const secrets = React.useMemo(
-    () => [gitHubToken, token].filter((secret) => secret !== null),
-    [gitHubToken, token],
+    () => [gitHubToken].filter((secret) => secret !== null),
+    [gitHubToken],
   );
 
   const Terminal = React.useMemo(
@@ -62,10 +67,9 @@ function App() {
   return (
     <WsClientProvider
       enabled
-      token={token}
       ghToken={gitHubToken}
       selectedRepository={selectedRepository}
-      settings={settings}
+      conversationId={conversationId}
     >
       <EventHandler>
         <div className="flex flex-col h-full gap-3">
@@ -120,4 +124,12 @@ function App() {
   );
 }
 
+function App() {
+  return (
+    <ConversationProvider>
+      <AppContent />
+    </ConversationProvider>
+  );
+}
+
 export default App;
diff --git a/frontend/src/utils/download-workspace.ts b/frontend/src/utils/download-workspace.ts
index dc79141d5f5d..324a5c26aca2 100644
--- a/frontend/src/utils/download-workspace.ts
+++ b/frontend/src/utils/download-workspace.ts
@@ -3,8 +3,8 @@ import OpenHands from "#/api/open-hands";
 /**
  * Downloads the current workspace as a .zip file.
  */
-export const downloadWorkspace = async () => {
-  const blob = await OpenHands.getWorkspaceZip();
+export const downloadWorkspace = async (conversationId: string) => {
+  const blob = await OpenHands.getWorkspaceZip(conversationId);
 
   const url = URL.createObjectURL(blob);
   const link = document.createElement("a");
diff --git a/frontend/test-utils.tsx b/frontend/test-utils.tsx
index 4b336602fbf6..29254c6b02b1 100644
--- a/frontend/test-utils.tsx
+++ b/frontend/test-utils.tsx
@@ -2,13 +2,25 @@
 
 import React, { PropsWithChildren } from "react";
 import { Provider } from "react-redux";
+import * as router from "react-router";
 import { configureStore } from "@reduxjs/toolkit";
 // eslint-disable-next-line import/no-extraneous-dependencies
 import { RenderOptions, render } from "@testing-library/react";
 import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
 import { AppStore, RootState, rootReducer } from "./src/store";
+import { vi } from "vitest";
 import { AuthProvider } from "#/context/auth-context";
 import { UserPrefsProvider } from "#/context/user-prefs-context";
+import { ConversationProvider } from "#/context/conversation-context";
+
+// Mock useParams before importing components
+vi.mock("react-router", async () => {
+  const actual = await vi.importActual("react-router");
+  return {
+    ...actual as object,
+    useParams: () => ({ conversationId: "test-conversation-id" }),
+  };
+});
 
 const setupStore = (preloadedState?: Partial<RootState>): AppStore =>
   configureStore({
@@ -40,7 +52,9 @@ export function renderWithProviders(
         <UserPrefsProvider>
           <AuthProvider>
             <QueryClientProvider client={new QueryClient()}>
-              {children}
+              <ConversationProvider>
+                {children}
+              </ConversationProvider>
             </QueryClientProvider>
           </AuthProvider>
         </UserPrefsProvider>
diff --git a/openhands/core/config/app_config.py b/openhands/core/config/app_config.py
index 9e883860cb5e..f113f8817ce8 100644
--- a/openhands/core/config/app_config.py
+++ b/openhands/core/config/app_config.py
@@ -68,7 +68,7 @@ class AppConfig:
     disable_color: bool = False
     jwt_secret: str = uuid.uuid4().hex
     attach_session_middleware_class: str = (
-        'openhands.server.middleware.AttachSessionMiddleware'
+        'openhands.server.middleware.AttachConversationMiddleware'
     )
     debug: bool = False
     file_uploads_max_file_size_mb: int = 0
diff --git a/openhands/server/app.py b/openhands/server/app.py
index b16879597824..20e80d9b56c5 100644
--- a/openhands/server/app.py
+++ b/openhands/server/app.py
@@ -9,16 +9,16 @@
 
 import openhands.agenthub  # noqa F401 (we import this to get the agents registered)
 from openhands.server.middleware import (
-    AttachSessionMiddleware,
+    AttachConversationMiddleware,
     InMemoryRateLimiter,
     LocalhostCORSMiddleware,
     NoCacheMiddleware,
     RateLimitMiddleware,
 )
 from openhands.server.routes.auth import app as auth_api_router
-from openhands.server.routes.conversation import app as conversation_api_router
 from openhands.server.routes.feedback import app as feedback_api_router
 from openhands.server.routes.files import app as files_api_router
+from openhands.server.routes.new_conversation import app as new_conversation_api_router
 from openhands.server.routes.public import app as public_api_router
 from openhands.server.routes.security import app as security_api_router
 from openhands.server.shared import config
@@ -46,20 +46,11 @@ async def health():
 app.include_router(auth_api_router)
 app.include_router(public_api_router)
 app.include_router(files_api_router)
-app.include_router(conversation_api_router)
 app.include_router(security_api_router)
 app.include_router(feedback_api_router)
+app.include_router(new_conversation_api_router)
 
-AttachSessionMiddlewareImpl = get_impl(
-    AttachSessionMiddleware, config.attach_session_middleware_class
-)
-app.middleware('http')(AttachSessionMiddlewareImpl(app, target_router=files_api_router))
-app.middleware('http')(
-    AttachSessionMiddlewareImpl(app, target_router=conversation_api_router)
-)
-app.middleware('http')(
-    AttachSessionMiddlewareImpl(app, target_router=security_api_router)
-)
-app.middleware('http')(
-    AttachSessionMiddlewareImpl(app, target_router=feedback_api_router)
+AttachConversationMiddlewareImpl = get_impl(
+    AttachConversationMiddleware, config.attach_session_middleware_class
 )
+app.middleware('http')(AttachConversationMiddlewareImpl(app))
diff --git a/openhands/server/listen_socket.py b/openhands/server/listen_socket.py
index 9946aafefe47..949768c936c0 100644
--- a/openhands/server/listen_socket.py
+++ b/openhands/server/listen_socket.py
@@ -1,7 +1,6 @@
-from fastapi import status
+from urllib.parse import parse_qs
 
 from openhands.core.logger import openhands_logger as logger
-from openhands.core.schema.action import ActionType
 from openhands.events.action import (
     NullAction,
 )
@@ -11,67 +10,23 @@
 from openhands.events.observation.agent import AgentStateChangedObservation
 from openhands.events.serialization import event_to_dict
 from openhands.events.stream import AsyncEventStreamWrapper
-from openhands.server.auth import get_sid_from_token, sign_token
-from openhands.server.github_utils import authenticate_github_user
-from openhands.server.session.session_init_data import SessionInitData
-from openhands.server.shared import config, session_manager, sio
+from openhands.server.shared import session_manager, sio
 
 
 @sio.event
-async def connect(connection_id: str, environ):
+async def connect(connection_id: str, environ, auth):
+    print('connect', auth)
     logger.info(f'sio:connect: {connection_id}')
-
-
-@sio.event
-async def oh_action(connection_id: str, data: dict):
-    # If it's an init, we do it here.
-    action = data.get('action', '')
-    if action == ActionType.INIT:
-        token = data.pop('token', None)
-        github_token = data.pop('github_token', None)
-        latest_event_id = int(data.pop('latest_event_id', -1))
-        kwargs = {k.lower(): v for k, v in (data.get('args') or {}).items()}
-        session_init_data = SessionInitData(**kwargs)
-        session_init_data.github_token = github_token
-        session_init_data.selected_repository = data.get('selected_repository', None)
-        await init_connection(
-            connection_id, token, github_token, session_init_data, latest_event_id
-        )
+    query_params = parse_qs(environ.get('QUERY_STRING', ''))
+    latest_event_id = int(query_params.get('latest_event_id', [-1])[0])
+    conversation_id = query_params.get('conversation_id', [None])[0]
+    if not conversation_id:
+        logger.error('No conversation_id in query params')
         return
-
-    logger.info(f'sio:oh_action:{connection_id}')
-    await session_manager.send_to_event_stream(connection_id, data)
-
-
-async def init_connection(
-    connection_id: str,
-    token: str | None,
-    gh_token: str | None,
-    session_init_data: SessionInitData,
-    latest_event_id: int,
-):
-    if not await authenticate_github_user(gh_token):
-        raise RuntimeError(status.WS_1008_POLICY_VIOLATION)
-
-    if token:
-        sid = get_sid_from_token(token, config.jwt_secret)
-        if sid == '':
-            await sio.emit('oh_event', {'error': 'Invalid token', 'error_code': 401})
-            return
-        logger.info(f'Existing session: {sid}')
-    else:
-        sid = connection_id
-        logger.info(f'New session: {sid}')
-
-    token = sign_token({'sid': sid}, config.jwt_secret)
-    await sio.emit('oh_event', {'token': token, 'status': 'ok'}, to=connection_id)
-
-    # The session in question should exist, but may not actually be running locally...
-    event_stream = await session_manager.init_or_join_session(
-        sid, connection_id, session_init_data
+    event_stream = await session_manager.join_conversation(
+        conversation_id, connection_id
     )
 
-    # Send events
     agent_state_changed = None
     async_stream = AsyncEventStreamWrapper(event_stream, latest_event_id + 1)
     async for event in async_stream:
@@ -91,6 +46,11 @@ async def init_connection(
         await sio.emit('oh_event', event_to_dict(agent_state_changed), to=connection_id)
 
 
+@sio.event
+async def oh_action(connection_id: str, data: dict):
+    await session_manager.send_to_event_stream(connection_id, data)
+
+
 @sio.event
 async def disconnect(connection_id: str):
     logger.info(f'sio:disconnect:{connection_id}')
diff --git a/openhands/server/middleware.py b/openhands/server/middleware.py
index 803887471e0e..70ae59f1d94c 100644
--- a/openhands/server/middleware.py
+++ b/openhands/server/middleware.py
@@ -5,14 +5,13 @@
 from urllib.parse import urlparse
 
 import jwt
-from fastapi import APIRouter, Request, status
+from fastapi import Request, status
 from fastapi.middleware.cors import CORSMiddleware
 from fastapi.responses import JSONResponse
 from starlette.middleware.base import BaseHTTPMiddleware
 from starlette.types import ASGIApp
 
 from openhands.core.logger import openhands_logger as logger
-from openhands.server.auth import get_sid_from_token
 from openhands.server.github_utils import UserVerifier
 from openhands.server.shared import config, session_manager
 
@@ -109,23 +108,22 @@ async def dispatch(self, request, call_next):
         return await call_next(request)
 
 
-class AttachSessionMiddleware:
-    def __init__(self, app, target_router: APIRouter):
+class AttachConversationMiddleware:
+    def __init__(self, app):
         self.app = app
-        self.target_router = target_router
-        self.target_paths = {route.path for route in target_router.routes}
 
     async def __call__(self, request: Request, call_next: Callable):
-        do_attach = False
-        if request.url.path in self.target_paths:
-            do_attach = True
-
-        if request.method == 'OPTIONS':
-            do_attach = False
-
-        if not do_attach:
+        conversation_id = None
+        if request.url.path.startswith('/api/conversation'):
+            # FIXME: we should be able to use path_params
+            path_parts = request.url.path.split('/')
+            if len(path_parts) > 3:
+                conversation_id = request.url.path.split('/')[3]
+        if not conversation_id:
             return await call_next(request)
 
+        request.state.sid = conversation_id
+
         user_verifier = UserVerifier()
         if user_verifier.is_active():
             signed_token = request.cookies.get('github_auth')
@@ -143,25 +141,6 @@ async def __call__(self, request: Request, call_next: Callable):
                     content={'error': 'Invalid token'},
                 )
 
-        if not request.headers.get('Authorization'):
-            logger.warning('Missing Authorization header')
-            return JSONResponse(
-                status_code=status.HTTP_401_UNAUTHORIZED,
-                content={'error': 'Missing Authorization header'},
-            )
-
-        auth_token = request.headers.get('Authorization')
-        if 'Bearer' in auth_token:
-            auth_token = auth_token.split('Bearer')[1].strip()
-
-        request.state.sid = get_sid_from_token(auth_token, config.jwt_secret)
-        if request.state.sid == '':
-            logger.warning('Invalid token')
-            return JSONResponse(
-                status_code=status.HTTP_401_UNAUTHORIZED,
-                content={'error': 'Invalid token'},
-            )
-
         request.state.conversation = await session_manager.attach_to_conversation(
             request.state.sid
         )
diff --git a/openhands/server/routes/conversation.py b/openhands/server/routes/conversation.py
index a47eed68ef40..8f0fb03b3a61 100644
--- a/openhands/server/routes/conversation.py
+++ b/openhands/server/routes/conversation.py
@@ -4,10 +4,10 @@
 from openhands.core.logger import openhands_logger as logger
 from openhands.runtime.base import Runtime
 
-app = APIRouter(prefix='/api')
+app = APIRouter(prefix='/api/conversation/{conversation_id}')
 
 
-@app.get('/conversation')
+@app.get('/config')
 async def get_remote_runtime_config(request: Request):
     """Retrieve the runtime configuration.
 
diff --git a/openhands/server/routes/feedback.py b/openhands/server/routes/feedback.py
index 8489ec84e68b..1fcb1b2abd80 100644
--- a/openhands/server/routes/feedback.py
+++ b/openhands/server/routes/feedback.py
@@ -5,14 +5,13 @@
 from openhands.events.serialization import event_to_dict
 from openhands.events.stream import AsyncEventStreamWrapper
 from openhands.server.data_models.feedback import FeedbackDataModel, store_feedback
-from openhands.server.shared import config
 from openhands.utils.async_utils import call_sync_from_async
 
-app = APIRouter(prefix='/api')
+app = APIRouter(prefix='/api/conversation/{conversation_id}')
 
 
 @app.post('/submit-feedback')
-async def submit_feedback(request: Request):
+async def submit_feedback(request: Request, conversation_id: str):
     """Submit user feedback.
 
     This function stores the provided feedback data.
@@ -57,18 +56,3 @@ async def submit_feedback(request: Request):
         return JSONResponse(
             status_code=500, content={'error': 'Failed to submit feedback'}
         )
-
-
-@app.get('/api/defaults')
-async def appconfig_defaults():
-    """Retrieve the default configuration settings.
-
-    To get the default configurations:
-    ```sh
-    curl http://localhost:3000/api/defaults
-    ```
-
-    Returns:
-        dict: The default configuration settings.
-    """
-    return config.defaults_dict
diff --git a/openhands/server/routes/files.py b/openhands/server/routes/files.py
index c2d37350c823..a199ff086c94 100644
--- a/openhands/server/routes/files.py
+++ b/openhands/server/routes/files.py
@@ -32,11 +32,11 @@
 )
 from openhands.utils.async_utils import call_sync_from_async
 
-app = APIRouter(prefix='/api')
+app = APIRouter(prefix='/api/conversation/{conversation_id}')
 
 
 @app.get('/list-files')
-async def list_files(request: Request, path: str | None = None):
+async def list_files(request: Request, conversation_id: str, path: str | None = None):
     """List files in the specified path.
 
     This function retrieves a list of files from the agent's runtime file store,
@@ -148,7 +148,7 @@ async def select_file(file: str, request: Request):
 
 
 @app.post('/upload-files')
-async def upload_file(request: Request, files: list[UploadFile]):
+async def upload_file(request: Request, conversation_id: str, files: list[UploadFile]):
     """Upload a list of files to the workspace.
 
     To upload a files:
@@ -310,7 +310,9 @@ async def save_file(request: Request):
 
 
 @app.get('/zip-directory')
-async def zip_current_workspace(request: Request, background_tasks: BackgroundTasks):
+async def zip_current_workspace(
+    request: Request, conversation_id: str, background_tasks: BackgroundTasks
+):
     try:
         logger.debug('Zipping workspace')
         runtime: Runtime = request.state.conversation.runtime
diff --git a/openhands/server/routes/new_conversation.py b/openhands/server/routes/new_conversation.py
new file mode 100644
index 000000000000..3fd0087a6364
--- /dev/null
+++ b/openhands/server/routes/new_conversation.py
@@ -0,0 +1,35 @@
+import uuid
+
+from fastapi import APIRouter, Request
+from fastapi.responses import JSONResponse
+from pydantic import BaseModel
+
+from openhands.server.session.session_init_data import SessionInitData
+from openhands.server.shared import session_manager
+
+app = APIRouter(prefix='/api')
+
+
+class InitSessionRequest(BaseModel):
+    token: str | None = None
+    github_token: str | None = None
+    latest_event_id: int = -1
+    args: dict | None = None
+    selected_repository: str | None = None
+
+
+@app.post('/conversation')
+async def init_session(request: Request, data: InitSessionRequest):
+    """Initialize a new session or join an existing one.
+
+    This endpoint replaces the WebSocket INIT event with a REST API call.
+    After successful initialization, the client should connect to the WebSocket
+    using the returned token.
+    """
+    kwargs = {k.lower(): v for k, v in (data.args or {}).items()}
+    session_init_data = SessionInitData(**kwargs)
+    session_init_data.github_token = data.github_token
+    session_init_data.selected_repository = data.selected_repository
+    conversation_id = uuid.uuid4().hex
+    await session_manager.start_agent_loop(conversation_id, session_init_data)
+    return JSONResponse(content={'status': 'ok', 'conversation_id': conversation_id})
diff --git a/openhands/server/routes/security.py b/openhands/server/routes/security.py
index f65fcce6aa3b..307019332506 100644
--- a/openhands/server/routes/security.py
+++ b/openhands/server/routes/security.py
@@ -4,7 +4,7 @@
     Request,
 )
 
-app = APIRouter(prefix='/api')
+app = APIRouter(prefix='/api/conversation/{conversation_id}')
 
 
 @app.route('/security/{path:path}', methods=['GET', 'POST', 'PUT', 'DELETE'])
diff --git a/openhands/server/session/manager.py b/openhands/server/session/manager.py
index 10b34b5dd259..04e608e8691f 100644
--- a/openhands/server/session/manager.py
+++ b/openhands/server/session/manager.py
@@ -142,7 +142,7 @@ async def attach_to_conversation(self, sid: str) -> Conversation | None:
     async def detach_from_conversation(self, conversation: Conversation):
         await conversation.disconnect()
 
-    async def init_or_join_session(self, sid: str, connection_id: str, session_init_data: SessionInitData):
+    async def join_conversation(self, sid: str, connection_id: str):
         await self.sio.enter_room(connection_id, ROOM_KEY.format(sid=sid))
         self.local_connection_id_to_session_id[connection_id] = sid
 
@@ -157,7 +157,7 @@ async def init_or_join_session(self, sid: str, connection_id: str, session_init_
         if redis_client and await self._is_session_running_in_cluster(sid):
             return EventStream(sid, self.file_store)
 
-        return await self.start_local_session(sid, session_init_data)
+        raise RuntimeError(f'no_connected_session:{connection_id}:{sid}')
 
     async def _is_session_running_in_cluster(self, sid: str) -> bool:
         """As the rest of the cluster if a session is running. Wait a for a short timeout for a reply"""
@@ -211,9 +211,8 @@ async def _has_remote_connections(self, sid: str) -> bool:
         finally:
             self._has_remote_connections_flags.pop(sid)
 
-    async def start_local_session(self, sid: str, session_init_data: SessionInitData):
-        # Start a new local session
-        logger.info(f'start_new_local_session:{sid}')
+    async def start_agent_loop(self, sid: str, session_init_data: SessionInitData):
+        logger.info(f'start_agent_loop:{sid}')
         session = Session(
             sid=sid, file_store=self.file_store, config=self.config, sio=self.sio
         )


Code review comments:
path: frontend/src/i18n/en.ts
text: @user1:
This looks like it is misplaced

diff_hunk: @@ -57,21 +64,37 @@ export const TaskForm = React.forwardRef<HTMLFormElement>((_, ref) => {
     return "What do you want to build?";
   }, [selectedRepository]);
 
-  const handleSubmit = (event: React.FormEvent<HTMLFormElement>) => {
+  const handleSubmit = async (event: React.FormEvent<HTMLFormElement>) => {
     event.preventDefault();
     const formData = new FormData(event.currentTarget);
 
     const q = formData.get("q")?.toString();
-    if (q) dispatch(setInitialQuery(q));
+    if (!q) return;
+
+    dispatch(setInitialQuery(q));
 
-    posthog.capture("initial_query_submitted", {
-      entry_point: "task_form",
-      query_character_length: q?.length,
-      has_repository: !!selectedRepository,
-      has_files: files.length > 0,
-    });
+    try {
+      setIsInitializing(true);
+      // Initialize the session before navigating
+      const { conversation_id: conversationId } = await OpenHands.initSession({
+        githubToken: gitHubToken || undefined,
+        selectedRepository: selectedRepository || undefined,
+        args: settings || undefined,
+      });
 
-    navigate("/app");
+      posthog.capture("initial_query_submitted", {
+        entry_point: "task_form",
+        query_character_length: q.length,
+        has_repository: !!selectedRepository,
+        has_files: files.length > 0,
+      });
+
+      navigate(`/conversation/${conversationId}`);
+    } catch (error) {
+      // TODO: Show error toast
+    } finally {
+      setIsInitializing(false);
+    }
path: frontend/src/components/shared/task-form.tsx
text: @user1:
We could move this into a `useMutation` hook: https://tanstack.com/query/latest/docs/framework/react/guides/mutations

diff_hunk: @@ -173,7 +150,7 @@ export function WsClientProvider({
       sio.off("connect_failed", handleError);
       sio.off("disconnect", handleDisconnect);
     };
-  }, [enabled, token, ghToken, selectedRepository]);
+  }, [enabled, ghToken, selectedRepository]);
path: frontend/src/context/ws-client-provider.tsx
text: @user1:
Wouldn't we want the effect to trigger when `conversationId` changes instead of `token`?

diff_hunk: @@ -34,7 +32,8 @@ function Home() {
   });
 
   React.useEffect(() => {
-    if (token) navigate("/app");
+    // If we have a token but no conversation ID, we need to initialize a new session
+    // This will be handled by the TaskForm
   }, [location.pathname]);
path: frontend/src/routes/_oh._index/route.tsx
text: @user1:
Might as well remove the entire `useEffect` statement